feat: add gallery folders, role storage controls and secure upload flows
This commit is contained in:
1 parent
d629f0fc07
commit
ef258ade7d
229 files changed
+18916
-11546
No files matched your search
@@ -0,0 +1,26 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*_test.go
|
||||
frontend/tests
|
||||
data
|
||||
uploads
|
||||
backups
|
||||
artifacts
|
||||
test-files
|
||||
deploy
|
||||
docs
|
||||
backend/docs
|
||||
admin-credentials.json
|
||||
sample-manifest.json
|
||||
Dockerfile.deploy
|
||||
Dockerfile.deploy.dockerignore
|
||||
compose.deploy.yaml
|
||||
DEPLOYMENT.md
|
||||
*.log
|
||||
*.dump
|
||||
oneimg
|
||||
+25
-4
@@ -1,7 +1,28 @@
|
||||
.env
|
||||
data
|
||||
.env.*
|
||||
!.env.example
|
||||
/data/
|
||||
/uploads/
|
||||
/oneimg
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
oneimg
|
||||
uploads/
|
||||
.claude/
|
||||
.claude/
|
||||
|
||||
# Local credentials, deployment files and verification artifacts
|
||||
/admin-credentials.json
|
||||
/Dockerfile.deploy
|
||||
/Dockerfile.deploy.dockerignore
|
||||
/compose.deploy.yaml
|
||||
/DEPLOYMENT.md
|
||||
/deploy/
|
||||
/backups/
|
||||
/artifacts/
|
||||
/test-files/
|
||||
/sample-manifest.json
|
||||
*.log
|
||||
*.dump
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+20
-49
@@ -1,60 +1,31 @@
|
||||
# 阶段1:构建前端
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# 安装pnpm并构建前端
|
||||
RUN npm install -g pnpm
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:22-alpine3.23 AS frontend-builder
|
||||
ENV NODE_OPTIONS=--max-old-space-size=768
|
||||
WORKDIR /src/frontend
|
||||
RUN npm install --global pnpm@10.17.1
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
|
||||
COPY frontend/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
|
||||
# 阶段2:构建后端
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
|
||||
# 安装CGO编译依赖
|
||||
FROM golang:1.26-alpine3.23 AS backend-builder
|
||||
ENV GOMAXPROCS=2 GOMEMLIMIT=768MiB GOGC=50 CGO_ENABLED=1
|
||||
RUN apk add --no-cache gcc g++ musl-dev libwebp-dev
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制Go依赖文件并下载
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# 复制后端源代码
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY backend/ ./backend/
|
||||
COPY main.go ./
|
||||
COPY --from=frontend-builder /src/frontend/dist ./frontend/dist
|
||||
COPY --from=frontend-builder /src/frontend/src/assets/fonts ./frontend/src/assets/fonts
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -p 1 -trimpath -ldflags="-s -w" -o /out/oneimg ./main.go
|
||||
|
||||
# 复制前端构建结果到后端可访问的路径
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
COPY --from=frontend-builder /app/frontend/src/assets/fonts/ ./frontend/src/assets/fonts/
|
||||
|
||||
# 编译后端应用(启用CGO支持webp)
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o main ./main.go
|
||||
|
||||
|
||||
# 阶段3:最终运行环境
|
||||
FROM alpine:3.18
|
||||
|
||||
# 安装运行时依赖
|
||||
RUN apk --no-cache add \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
libwebp
|
||||
|
||||
# 设置工作目录
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache ca-certificates tzdata libwebp
|
||||
WORKDIR /app
|
||||
|
||||
# 从后端构建阶段复制二进制文件
|
||||
COPY --from=backend-builder /app/main ./
|
||||
|
||||
# 复制前端构建产物
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
|
||||
# 暴露端口
|
||||
COPY --from=backend-builder /out/oneimg /app/oneimg
|
||||
COPY --from=frontend-builder /src/frontend/dist ./frontend/dist
|
||||
COPY ca/ ./ca/
|
||||
RUN mkdir -p /app/data /app/uploads
|
||||
EXPOSE 8080
|
||||
|
||||
# 运行应用
|
||||
CMD ["./main"]
|
||||
CMD ["/app/oneimg"]
|
||||
+98
-59
@@ -1,12 +1,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"log"
|
||||
"oneimg/backend/utils/settings"
|
||||
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"oneimg/backend/utils/images"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -29,16 +34,36 @@ func Init() *System {
|
||||
database.InitDB(cfg)
|
||||
db := database.GetDB()
|
||||
|
||||
if err := InitializeDefaults(cfg, db.DB); err != nil {
|
||||
log.Fatalf("默认数据初始化失败: %v", err)
|
||||
}
|
||||
if err := settings.MigrateSecrets(db.DB); err != nil {
|
||||
log.Fatalf("敏感配置迁移失败: %v", err)
|
||||
}
|
||||
if err := authsecurity.EnsureState(db.DB); err != nil {
|
||||
log.Fatalf("认证状态初始化失败: %v", err)
|
||||
}
|
||||
var storedSettings models.Settings
|
||||
if err := db.DB.First(&storedSettings, 1).Error; err != nil {
|
||||
log.Fatalf("认证配置读取失败: %v", err)
|
||||
}
|
||||
if err := authsecurity.EnrollLegacyToken(db.DB, storedSettings); err != nil {
|
||||
log.Fatalf("旧 API 凭据迁移失败: %v", err)
|
||||
}
|
||||
if err := settings.RegisterInvalidation(db.DB); err != nil {
|
||||
log.Fatalf("系统配置缓存初始化失败: %v", err)
|
||||
}
|
||||
images.InitImageService()
|
||||
InitDefaultUser(cfg, db)
|
||||
InitSettings(db)
|
||||
InitDefaultStorage(db)
|
||||
|
||||
// 为旧图片补齐存储副本记录(幂等),再启动同步 worker。
|
||||
if err := services.BackfillImageStorages(); err != nil {
|
||||
log.Printf("图片存储副本回填失败: %v", err)
|
||||
log.Fatalf("图片存储副本回填失败: %v", err)
|
||||
}
|
||||
if err := database.ReconcileLegacyBucketUsage(db.DB); err != nil {
|
||||
log.Fatalf("历史存储容量核对失败: %v", err)
|
||||
}
|
||||
services.StartStorageSyncWorker()
|
||||
services.StartDirectUploadWorker()
|
||||
|
||||
return &System{
|
||||
Config: cfg,
|
||||
@@ -52,71 +77,85 @@ func hashPassword(password string) (string, error) {
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// InitDefaultUser 在空库时创建默认管理员。
|
||||
func InitDefaultUser(cfg *config.Config, db *database.Database) {
|
||||
var count int64
|
||||
db.DB.Model(&models.User{}).Count(&count)
|
||||
if count > 0 {
|
||||
log.Println("用户已存在,跳过默认用户初始化")
|
||||
return
|
||||
}
|
||||
// InitializeDefaults seeds only missing defaults in one transaction. It never
|
||||
// resets an existing settings row, storage configuration or image ownership.
|
||||
func InitializeDefaults(cfg *config.Config, db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := initDefaultUser(cfg, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initSettings(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initDefaultStorage(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateLegacyData(tx)
|
||||
})
|
||||
}
|
||||
|
||||
hashedPassword, err := hashPassword(cfg.DefaultPass)
|
||||
func initDefaultUser(cfg *config.Config, tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&models.User{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
initialPassword, err := config.InitialAdminPassword(cfg)
|
||||
if err != nil {
|
||||
log.Fatal("密码加密失败:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
defaultUser := models.User{
|
||||
Username: cfg.DefaultUser,
|
||||
Role: models.RoleAdmin,
|
||||
Password: hashedPassword,
|
||||
password, err := hashPassword(initialPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result := db.DB.Create(&defaultUser); result.Error != nil {
|
||||
log.Fatal("创建默认用户失败:", result.Error)
|
||||
return tx.Create(&models.User{Username: cfg.DefaultUser, Role: models.RoleAdmin, Password: password}).Error
|
||||
}
|
||||
func initSettings(tx *gorm.DB) error {
|
||||
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(&models.Settings{ID: 1}).Error
|
||||
}
|
||||
func initDefaultStorage(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&models.Buckets{}).Where("type = ?", "default").Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("默认用户创建成功 - 用户名: %s", defaultUser.Username)
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
var occupied int64
|
||||
if err := tx.Model(&models.Buckets{}).Where("id = ?", 1).Count(&occupied).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if occupied > 0 {
|
||||
return fmt.Errorf("bucket ID=1 is occupied by nonlocal storage; refusing to overwrite")
|
||||
}
|
||||
storage := models.Buckets{Id: 1, Name: "本地默认存储", Type: "default", Capacity: 0, Config: map[string]any{"storagePath": config.UploadRoot()}, Usage: 0}
|
||||
if err := tx.Create(&storage).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return database.AdvanceBucketSequence(tx)
|
||||
}
|
||||
|
||||
// InitDefaultStorage 在空库时创建本地默认存储桶,并执行版本迁移。
|
||||
// Compatibility entrypoints retain their names, but errors are never ignored.
|
||||
func InitDefaultUser(cfg *config.Config, db *database.Database) {
|
||||
if err := db.DB.Transaction(func(tx *gorm.DB) error { return initDefaultUser(cfg, tx) }); err != nil {
|
||||
log.Fatal("默认用户初始化失败: ", err)
|
||||
}
|
||||
}
|
||||
func InitDefaultStorage(db *database.Database) {
|
||||
const storageType = "default"
|
||||
const storagePath = "/uploads"
|
||||
|
||||
var count int64
|
||||
db.DB.Model(&models.Buckets{}).Where("type = ?", storageType).Count(&count)
|
||||
if count > 0 {
|
||||
log.Println("存储配置已存在,跳过默认存储初始化")
|
||||
return
|
||||
if err := db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := initDefaultStorage(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateLegacyData(tx)
|
||||
}); err != nil {
|
||||
log.Fatal("默认存储初始化失败: ", err)
|
||||
}
|
||||
|
||||
storage := models.Buckets{
|
||||
Id: 1,
|
||||
Name: "本地默认存储",
|
||||
Type: storageType,
|
||||
Capacity: 0,
|
||||
Config: map[string]any{
|
||||
"storagePath": storagePath,
|
||||
},
|
||||
Usage: 0,
|
||||
}
|
||||
if result := db.DB.Create(&storage); result.Error != nil {
|
||||
log.Fatal("创建默认存储配置失败:", result.Error)
|
||||
}
|
||||
log.Printf("默认存储配置创建成功 - 类型: %s, 路径: %s", storage.Type, storagePath)
|
||||
|
||||
Migrate(db)
|
||||
}
|
||||
|
||||
// InitSettings 在空库时创建默认系统设置行。
|
||||
func InitSettings(db *database.Database) {
|
||||
var count int64
|
||||
db.DB.Model(&models.Settings{}).Count(&count)
|
||||
if count > 0 {
|
||||
log.Println("系统配置已存在,跳过系统配置初始化")
|
||||
return
|
||||
if err := db.DB.Transaction(initSettings); err != nil {
|
||||
log.Fatal("系统设置初始化失败: ", err)
|
||||
}
|
||||
if result := db.DB.Create(&models.Settings{}); result.Error != nil {
|
||||
log.Fatal("创建系统配置失败:", result.Error)
|
||||
}
|
||||
log.Printf("系统配置创建成功")
|
||||
settings.Invalidate()
|
||||
}
|
||||
+42
-106
@@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
@@ -8,118 +10,52 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *database.Database) {
|
||||
var images []models.Image
|
||||
defaultBucket := make(map[int]models.Image)
|
||||
s3Bucket := make(map[int]models.Image)
|
||||
r2Bucket := make(map[int]models.Image)
|
||||
ftpBucket := make(map[int]models.Image)
|
||||
webdavBucket := make(map[int]models.Image)
|
||||
telegramBucket := make(map[int]models.Image)
|
||||
|
||||
if err := db.DB.Find(&images).Error; err != nil {
|
||||
log.Printf("[数据迁移] 查询图片列表失败: %s", err.Error())
|
||||
return
|
||||
if err := db.DB.Transaction(migrateLegacyData); err != nil {
|
||||
log.Printf("[数据迁移] 失败(已回滚): %v", err)
|
||||
}
|
||||
log.Printf("[数据迁移] 共查询到 %d 张图片,开始分类处理", len(images))
|
||||
|
||||
for _, image := range images {
|
||||
switch image.Storage {
|
||||
case "default":
|
||||
defaultBucket[image.Id] = image
|
||||
case "s3":
|
||||
s3Bucket[image.Id] = image
|
||||
case "r2":
|
||||
r2Bucket[image.Id] = image
|
||||
case "ftp":
|
||||
ftpBucket[image.Id] = image
|
||||
case "webdav":
|
||||
webdavBucket[image.Id] = image
|
||||
case "telegram":
|
||||
telegramBucket[image.Id] = image
|
||||
}
|
||||
}
|
||||
|
||||
if len(defaultBucket) > 0 {
|
||||
log.Printf("[数据迁移-default] 共处理 %d 张图片", len(defaultBucket))
|
||||
err := db.DB.Model(&models.Image{}).Where("storage = ?", "default").Updates(map[string]any{
|
||||
"bucket_id": 1,
|
||||
"storage": "default",
|
||||
}).Error
|
||||
if err != nil {
|
||||
log.Printf("[数据迁移-default] 批量更新图片失败: %s", err.Error())
|
||||
} else {
|
||||
log.Printf("[数据迁移-default] 图片绑定桶ID=1 完成")
|
||||
}
|
||||
}
|
||||
|
||||
handleBucketMigrate(db, "s3", 2, "S3对象存储", s3Bucket)
|
||||
handleBucketMigrate(db, "r2", 3, "Cloudflare R2存储", r2Bucket)
|
||||
handleBucketMigrate(db, "ftp", 4, "FTP文件存储", ftpBucket)
|
||||
handleBucketMigrate(db, "webdav", 5, "WebDAV存储", webdavBucket)
|
||||
handleBucketMigrate(db, "telegram", 6, "Telegram存储", telegramBucket)
|
||||
|
||||
log.Printf("[数据迁移] 所有存储类型迁移流程执行完毕")
|
||||
|
||||
// 检查默认用户是否设置了角色
|
||||
var user models.User
|
||||
if err := db.DB.Where("id = ?", 1).First(&user).Error; err != nil {
|
||||
log.Printf("[数据迁移] 查询默认用户失败: %s", err.Error())
|
||||
return
|
||||
}
|
||||
if user.Role == 0 {
|
||||
log.Printf("[数据迁移] 默认用户未设置角色,将设置为 admin")
|
||||
user.Role = 1
|
||||
if err := db.DB.Save(&user).Error; err != nil {
|
||||
log.Printf("[数据迁移] 更新默认用户角色失败: %s", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Printf("[数据迁移] 默认用户角色设置为 admin")
|
||||
}
|
||||
|
||||
func handleBucketMigrate(db *database.Database, storageType string, bucketId int, bucketName string, imageMap map[int]models.Image) {
|
||||
imageCount := len(imageMap)
|
||||
if imageCount == 0 {
|
||||
return
|
||||
// Only migrate dangling/mismatched legacy bucket references. Existing buckets,
|
||||
// credentials and valid image bindings are untouched, and no image blobs/lists
|
||||
// are loaded into memory. Legacy empty provider configs still require an admin
|
||||
// to configure credentials, just as before; never infer production credentials.
|
||||
func migrateLegacyData(tx *gorm.DB) error {
|
||||
type legacyGroup struct {
|
||||
Storage string
|
||||
TotalUsage int64
|
||||
}
|
||||
log.Printf("[数据迁移-%s] 共处理 %d 张图片,开始创建存储桶+统计容量", storageType, imageCount)
|
||||
|
||||
tx := db.DB.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("[数据迁移-%s] 程序异常,事务回滚: %v", storageType, r)
|
||||
var groups []legacyGroup
|
||||
condition := "NOT EXISTS (SELECT 1 FROM buckets WHERE buckets.id = images.bucket_id AND buckets.type = images.storage)"
|
||||
if err := tx.Model(&models.Image{}).Select("storage, COALESCE(SUM(CASE WHEN file_size > 0 THEN file_size ELSE 0 END),0) AS total_usage").Where(condition).Group("storage").Scan(&groups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
names := map[string]string{"default": "本地默认存储", "s3": "S3对象存储", "r2": "Cloudflare R2存储", "ftp": "FTP文件存储", "webdav": "WebDAV存储", "telegram": "Telegram存储"}
|
||||
for _, group := range groups {
|
||||
name, ok := names[group.Storage]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown legacy storage type %q", group.Storage)
|
||||
}
|
||||
var bucket models.Buckets
|
||||
err := tx.Where("type = ?", group.Storage).Order("id ASC").First(&bucket).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
bucket = models.Buckets{Name: name, Type: group.Storage, Capacity: 1099511627776, Config: getBucketConfig(group.Storage), Usage: 0}
|
||||
if err = tx.Create(&bucket).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.Image{}).Where("storage = ?", group.Storage).Where(condition).Update("bucket_id", bucket.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if group.TotalUsage > 0 && group.Storage != "default" {
|
||||
if err := tx.Model(&models.Buckets{}).Where("id = ?", bucket.Id).UpdateColumn("usage", gorm.Expr(database.UsageColumn(tx)+" + ?", group.TotalUsage)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var totalUsage uint64
|
||||
for _, img := range imageMap {
|
||||
totalUsage += uint64(img.FileSize)
|
||||
}
|
||||
|
||||
bucket := models.Buckets{
|
||||
Id: bucketId,
|
||||
Name: bucketName,
|
||||
Type: storageType,
|
||||
Capacity: 1099511627776,
|
||||
Config: getBucketConfig(storageType),
|
||||
Usage: totalUsage,
|
||||
}
|
||||
|
||||
if err := tx.Create(&bucket).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("[数据迁移-%s] 创建存储桶失败,事务回滚: %s", storageType, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.Image{}).Where("storage = ?", storageType).Update("bucket_id", bucket.Id).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("[数据迁移-%s] 批量更新图片bucket_id失败,事务回滚: %s", storageType, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
log.Printf("[数据迁移-%s] 迁移完成 存储桶ID:%d 总图片数:%d 总占用容量:%d Byte", storageType, bucket.Id, imageCount, totalUsage)
|
||||
// Legacy zero role only; do not change any explicitly chosen user role.
|
||||
return tx.Model(&models.User{}).Where("id = ? AND role = ?", 1, 0).Update("role", models.RoleAdmin).Error
|
||||
}
|
||||
|
||||
func getBucketConfig(storageType string) map[string]any {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InitialAdminPassword must be called only after the database confirms it has
|
||||
// zero users. Explicit operator values remain unchanged; otherwise persist a
|
||||
// cryptographically random bootstrap password, never serialize/log its value.
|
||||
// Existing account passwords are never reset by configuration loading.
|
||||
func InitialAdminPassword(cfg *Config) (string, error) {
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("missing initial admin configuration")
|
||||
}
|
||||
if cfg.DefaultPass != "" {
|
||||
return cfg.DefaultPass, nil
|
||||
}
|
||||
return initialAdminPasswordFile("./data/.initial_admin_password")
|
||||
}
|
||||
func initialAdminPasswordFile(path string) (string, error) {
|
||||
if info, err := os.Lstat(path); err == nil {
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("bootstrap password must be a regular file")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read bootstrap password: %w", err)
|
||||
}
|
||||
secret := strings.TrimSuffix(string(data), "\n")
|
||||
if len(secret) < 32 || len(secret) > 72 || strings.ContainsAny(secret, "\r\n\t ") {
|
||||
return "", fmt.Errorf("invalid stored bootstrap password; refusing to replace")
|
||||
}
|
||||
if err := os.Chmod(path, 0600); err != nil {
|
||||
return "", fmt.Errorf("protect bootstrap password: %w", err)
|
||||
}
|
||||
return secret, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("inspect bootstrap password: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return "", fmt.Errorf("create bootstrap password directory: %w", err)
|
||||
}
|
||||
data := make([]byte, 32)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return "", fmt.Errorf("generate bootstrap password: %w", err)
|
||||
}
|
||||
secret := base64.RawURLEncoding.EncodeToString(data)
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create bootstrap password without overwriting: %w", err)
|
||||
}
|
||||
_, writeErr := file.WriteString(secret + "\n")
|
||||
if writeErr == nil {
|
||||
writeErr = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil {
|
||||
return "", fmt.Errorf("persist bootstrap password: %w", writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return "", fmt.Errorf("close bootstrap password: %w", closeErr)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
// Hard ceilings protect resources and match persistent column constraints.
|
||||
const (
|
||||
DefaultMaxUploadFiles = 10
|
||||
HardMaxUploadFiles = 50
|
||||
DefaultTagMaxLength = 10
|
||||
HardTagMaxLength = 50
|
||||
DefaultRandomImageLimit = 20
|
||||
HardRandomImageLimit = 100
|
||||
DefaultPowVerifyTimeoutSeconds = 5
|
||||
HardPowVerifyTimeoutSeconds = 15
|
||||
LegacyPowVerifyURL = "https://cha.eta.im/api/validate"
|
||||
)
|
||||
+53
-30
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
@@ -21,16 +22,21 @@ type Config struct {
|
||||
SqlitePath string
|
||||
|
||||
// 数据库配置
|
||||
DbType string
|
||||
DbHost string
|
||||
DbPort int
|
||||
DbUser string
|
||||
DbPassword string
|
||||
DbName string
|
||||
DbCaCertPath string
|
||||
DbSkipCertVerify bool
|
||||
DbType string
|
||||
DbHost string
|
||||
DbPort int
|
||||
DbUser string
|
||||
DbPassword string
|
||||
DbName string
|
||||
DbCaCertPath string
|
||||
DbSkipCertVerify bool
|
||||
DbMaxOpenConns int
|
||||
DbMaxIdleConns int
|
||||
DbConnMaxLifetime time.Duration
|
||||
DbConnMaxIdleTime time.Duration
|
||||
|
||||
// 上传文件配置
|
||||
UploadRoot string
|
||||
MaxFileSize int64
|
||||
AllowedTypes []string
|
||||
|
||||
@@ -92,17 +98,23 @@ DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=
|
||||
DB_NAME=oneimg
|
||||
# CA证书路径,如果不需要TLS加密连接则将其注释
|
||||
# CA证书路径。省略时使用系统 CA;证书验证默认开启,路径错误会拒绝启动。
|
||||
DB_CA_CERT_PATH=./ca/isrgrootx1.pem
|
||||
DB_SKIP_CERT_VERIFY=false
|
||||
DB_MAX_OPEN_CONNS=10
|
||||
DB_MAX_IDLE_CONNS=2
|
||||
DB_CONN_MAX_LIFETIME=30m
|
||||
DB_CONN_MAX_IDLE_TIME=5m
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_ROOT=./uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,image/svg+xml
|
||||
ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp
|
||||
|
||||
# 默认用户配置
|
||||
DEFAULT_USER=admin
|
||||
DEFAULT_PASS=123456
|
||||
# 留空时仅在首次创建管理员时生成强密码,保存于 data/.initial_admin_password(0600);不在日志输出
|
||||
DEFAULT_PASS=
|
||||
|
||||
# Session配置
|
||||
SESSION_SECRET=
|
||||
@@ -154,8 +166,14 @@ func loadOrCreatePersistentConfigSecret() string {
|
||||
const secretPath = "./data/.config_secret"
|
||||
if stored, err := os.ReadFile(secretPath); err == nil {
|
||||
if secret := strings.TrimSpace(string(stored)); secret != "" {
|
||||
if err := os.Chmod(secretPath, 0600); err != nil {
|
||||
log.Fatalf("保护配置密钥权限失败: %v", err)
|
||||
}
|
||||
return secret
|
||||
}
|
||||
log.Fatal("持久化配置密钥文件为空,拒绝生成替代密钥")
|
||||
} else if !os.IsNotExist(err) {
|
||||
log.Fatalf("读取持久化配置密钥失败(拒绝替换现有密钥): %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(secretPath), 0755); err != nil {
|
||||
@@ -206,7 +224,7 @@ func NewConfig() {
|
||||
|
||||
// 默认用户配置
|
||||
defaultUser := getEnv("DEFAULT_USER", "admin")
|
||||
defaultPass := getEnv("DEFAULT_PASS", "123456")
|
||||
defaultPass := getEnv("DEFAULT_PASS", "")
|
||||
|
||||
// JWT配置(默认生成随机密钥,避免硬编码)
|
||||
jwtSecret := getEnv("JWT_SECRET", generateRandomSecret(32))
|
||||
@@ -220,24 +238,29 @@ func NewConfig() {
|
||||
|
||||
// 初始化全局配置
|
||||
App = &Config{
|
||||
Port: port,
|
||||
AppURL: appURL,
|
||||
SqlitePath: sqlitePath,
|
||||
DbType: dbType,
|
||||
DbHost: dbHost,
|
||||
DbPort: dbPort,
|
||||
DbUser: dbUser,
|
||||
DbPassword: dbPassword,
|
||||
DbName: dbName,
|
||||
MaxFileSize: maxFileSize,
|
||||
AllowedTypes: allowedTypes,
|
||||
DefaultUser: defaultUser,
|
||||
DefaultPass: defaultPass,
|
||||
JWTSecret: jwtSecret,
|
||||
SessionSecret: sessionSecret,
|
||||
ConfigSecret: configSecret,
|
||||
DbCaCertPath: dbCaCertPath,
|
||||
DbSkipCertVerify: dbSkipCertVerify,
|
||||
Port: port,
|
||||
AppURL: appURL,
|
||||
SqlitePath: sqlitePath,
|
||||
DbType: dbType,
|
||||
DbHost: dbHost,
|
||||
DbPort: dbPort,
|
||||
DbUser: dbUser,
|
||||
DbPassword: dbPassword,
|
||||
DbName: dbName,
|
||||
MaxFileSize: maxFileSize,
|
||||
AllowedTypes: allowedTypes,
|
||||
DefaultUser: defaultUser,
|
||||
DefaultPass: defaultPass,
|
||||
JWTSecret: jwtSecret,
|
||||
SessionSecret: sessionSecret,
|
||||
ConfigSecret: configSecret,
|
||||
DbCaCertPath: dbCaCertPath,
|
||||
DbSkipCertVerify: dbSkipCertVerify,
|
||||
DbMaxOpenConns: positiveEnvInt("DB_MAX_OPEN_CONNS", 10),
|
||||
DbMaxIdleConns: nonnegativeEnvInt("DB_MAX_IDLE_CONNS", 2),
|
||||
DbConnMaxLifetime: positiveEnvDuration("DB_CONN_MAX_LIFETIME", 30*time.Minute),
|
||||
DbConnMaxIdleTime: positiveEnvDuration("DB_CONN_MAX_IDLE_TIME", 5*time.Minute),
|
||||
UploadRoot: getEnv("UPLOAD_ROOT", "./uploads"),
|
||||
}
|
||||
|
||||
log.Println("✅ 配置初始化完成")
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UploadRoot returns the configured filesystem root, not the public URL prefix.
|
||||
func UploadRoot() string {
|
||||
if App != nil && strings.TrimSpace(App.UploadRoot) != "" {
|
||||
return App.UploadRoot
|
||||
}
|
||||
return "./uploads"
|
||||
}
|
||||
|
||||
// LocalUploadPath maps stable /uploads URLs to files. Reject traversal before
|
||||
// cleaning it away, and reject symlinks in existing path components. The upload
|
||||
// directory must only be writable by the application (no hostile local writers).
|
||||
func LocalUploadPath(publicPath string) (string, error) {
|
||||
p := strings.SplitN(strings.TrimSpace(publicPath), "?", 2)[0]
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
if !strings.HasPrefix(p, "uploads/") || strings.Contains(p, "\\") || strings.ContainsRune(p, 0) {
|
||||
return "", fmt.Errorf("not a local upload path")
|
||||
}
|
||||
p = strings.TrimPrefix(p, "uploads/")
|
||||
for _, part := range strings.Split(p, "/") {
|
||||
if part == ".." {
|
||||
return "", fmt.Errorf("unsafe upload path")
|
||||
}
|
||||
}
|
||||
p = filepath.Clean(filepath.FromSlash(p))
|
||||
if p == "." || filepath.IsAbs(p) {
|
||||
return "", fmt.Errorf("unsafe upload path")
|
||||
}
|
||||
root, err := filepath.Abs(UploadRoot())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full := filepath.Join(root, p)
|
||||
rel, err := filepath.Rel(root, full)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("upload path escapes root")
|
||||
}
|
||||
current := root
|
||||
// A root symlink is also rejected; parents of the root are deployment-owned.
|
||||
components := append([]string{""}, strings.Split(rel, string(filepath.Separator))...)
|
||||
for _, part := range components {
|
||||
if part != "" {
|
||||
current = filepath.Join(current, part)
|
||||
}
|
||||
info, statErr := os.Lstat(current)
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
return "", statErr
|
||||
}
|
||||
if statErr == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("upload path contains symlink")
|
||||
}
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
func positiveEnvInt(key string, fallback int) int {
|
||||
n, err := strconv.Atoi(getEnv(key, ""))
|
||||
if err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
func nonnegativeEnvInt(key string, fallback int) int {
|
||||
n, err := strconv.Atoi(getEnv(key, ""))
|
||||
if err != nil || n < 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
func positiveEnvDuration(key string, fallback time.Duration) time.Duration {
|
||||
d, err := time.ParseDuration(getEnv(key, ""))
|
||||
if err != nil || d <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/storagepolicy"
|
||||
)
|
||||
|
||||
// AccessPolicyBucket intentionally excludes configuration, endpoints and secrets.
|
||||
type AccessPolicyBucket struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Capacity uint64 `json:"capacity"`
|
||||
Usage uint64 `json:"usage"`
|
||||
}
|
||||
type AccessPolicyResponse struct {
|
||||
MultiStorageSync bool `json:"multi_storage_sync"`
|
||||
GuestEnabled bool `json:"guest_enabled"`
|
||||
GuestStorageID int `json:"guest_storage_id"`
|
||||
EffectiveGuestStorageID int `json:"effective_guest_storage_id"`
|
||||
DefaultStorageID int `json:"default_storage_id"`
|
||||
Buckets []AccessPolicyBucket `json:"buckets"`
|
||||
Roles []storagepolicy.RoleSummary `json:"roles"`
|
||||
CanEditGuestStorage bool `json:"can_edit_guest_storage"`
|
||||
CanAssignUsers bool `json:"can_assign_users"`
|
||||
}
|
||||
|
||||
func canEditStoragePolicy(c *gin.Context, user *models.User) bool {
|
||||
// Exactly UpdateSettings rules; wildcard alone is NOT setting:upload.
|
||||
return c.GetString("auth_method") != "api_token" && (user.ID == models.SuperAdminID || user.Permission.HasPermission("setting:upload"))
|
||||
}
|
||||
func readStoragePolicyOverview() (models.Settings, []AccessPolicyBucket, []storagepolicy.RoleSummary, error) {
|
||||
s, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return s, nil, nil, err
|
||||
}
|
||||
var all []models.Buckets
|
||||
db := database.GetDB().DB
|
||||
if err := db.Select("id", "name", "type", "disabled", "capacity", "usage").Order("id ASC").Find(&all).Error; err != nil {
|
||||
return s, nil, nil, err
|
||||
}
|
||||
policy, err := storagepolicy.Load(db)
|
||||
if err != nil {
|
||||
return s, nil, nil, err
|
||||
}
|
||||
buckets := make([]AccessPolicyBucket, 0, len(all))
|
||||
for _, b := range all {
|
||||
buckets = append(buckets, AccessPolicyBucket{ID: b.Id, Name: b.Name, Type: b.Type, Disabled: b.Disabled, Capacity: b.Capacity, Usage: b.Usage})
|
||||
}
|
||||
return s, buckets, policy.Summaries(s, all), nil
|
||||
}
|
||||
|
||||
// Management-only route; deliberately does not require setting:list.
|
||||
func GetAccessPolicy(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
user, ok := middlewares.GetCurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(401, result.Error(401, "未登录"))
|
||||
return
|
||||
}
|
||||
s, buckets, roles, err := readStoragePolicyOverview()
|
||||
if err != nil {
|
||||
internalFailure(c, "获取访问策略失败", err)
|
||||
return
|
||||
}
|
||||
nonToken := c.GetString("auth_method") != "api_token"
|
||||
effectiveGuestID := s.EffectiveGuestStorageID()
|
||||
for _, role := range roles {
|
||||
if role.Role == models.RoleGuest && role.Mode == "custom" {
|
||||
effectiveGuestID = 0 // Use roles for empty/multi-source pools.
|
||||
if len(role.EffectiveBucketIDs) == 1 {
|
||||
effectiveGuestID = role.EffectiveBucketIDs[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("ok", AccessPolicyResponse{
|
||||
MultiStorageSync: s.MultiStorageSync, GuestEnabled: s.Tourist, GuestStorageID: s.GuestStorage,
|
||||
EffectiveGuestStorageID: effectiveGuestID, DefaultStorageID: s.DefaultStorage,
|
||||
Buckets: buckets, Roles: roles, CanEditGuestStorage: canEditStoragePolicy(c, user),
|
||||
CanAssignUsers: user.Permission.HasPermission("user:permission:update") || (nonToken && (user.ID == models.SuperAdminID || user.Permission.HasPermission("*"))),
|
||||
}))
|
||||
}
|
||||
func defaultUploadStorageID(role int, s models.Settings) int { return storagepolicy.DefaultID(role, s) }
|
||||
func validateGuestStorage(value any) error {
|
||||
id, err := strictSettingInt(value)
|
||||
if err != nil || id < 0 {
|
||||
return fmt.Errorf("游客存储必须是大于或等于 0 的整数")
|
||||
}
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
var bucket models.Buckets
|
||||
if err := database.GetDB().DB.Select("id", "disabled").First(&bucket, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("游客存储源不存在")
|
||||
}
|
||||
return fmt.Errorf("查询游客存储源失败")
|
||||
}
|
||||
if bucket.Disabled {
|
||||
return fmt.Errorf("游客存储源已停用,请先启用后再选择")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+163
-203
@@ -1,247 +1,207 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
|
||||
"errors"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"oneimg/backend/utils/result"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChangeAccountInfoRequest 修改账户信息请求体。
|
||||
type ChangeAccountInfoRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password"`
|
||||
NewUsername string `json:"new_username" binding:"max=64"`
|
||||
CurrentPassword string `json:"current_password" binding:"required,max=72"`
|
||||
NewPassword string `json:"new_password" binding:"max=72"`
|
||||
NewUsername string `json:"new_username" binding:"max=50"`
|
||||
}
|
||||
|
||||
// AccountResponse 账户相关接口响应体。
|
||||
type AccountResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// uuidRegex 匹配标准 UUID(游客用户名格式)。
|
||||
var uuidRegex = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// ChangeAccountInfo 修改当前登录用户的用户名/密码;成功后强制重新登录。
|
||||
func ChangeAccountInfo(c *gin.Context) {
|
||||
user, ok := middlewares.GetCurrentUser(c)
|
||||
if !ok || user.ID <= 0 || user.Role == models.RoleGuest || c.GetString("auth_method") == "api_token" {
|
||||
c.JSON(403, result.Error(403, "游客或 API 凭据不能修改账户"))
|
||||
return
|
||||
}
|
||||
var persisted models.User
|
||||
if err := database.GetDB().DB.First(&persisted, user.ID).Error; err != nil {
|
||||
c.JSON(401, result.Error(401, "用户已不存在"))
|
||||
return
|
||||
}
|
||||
var req ChangeAccountInfoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, AccountResponse{
|
||||
Code: 400,
|
||||
Message: "请求参数错误: " + err.Error(),
|
||||
Success: false,
|
||||
})
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, result.Error(400, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.NewPassword != "" && len(req.NewPassword) < 6 {
|
||||
c.JSON(http.StatusBadRequest, AccountResponse{
|
||||
Code: 400,
|
||||
Message: "新密码长度不能小于6",
|
||||
Success: false,
|
||||
})
|
||||
if bcrypt.CompareHashAndPassword([]byte(persisted.Password), []byte(req.CurrentPassword)) != nil {
|
||||
c.JSON(401, result.Error(401, "当前密码错误"))
|
||||
return
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, AccountResponse{
|
||||
Code: 401,
|
||||
Message: "未登录",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Session 角色可能是 int/float64/uint,统一转 int
|
||||
role := 0
|
||||
if userRole := session.Get("user_role"); userRole != nil {
|
||||
switch v := userRole.(type) {
|
||||
case int:
|
||||
role = v
|
||||
case float64:
|
||||
role = int(v)
|
||||
case uint:
|
||||
role = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
// 仅管理员可改用户名
|
||||
if role != models.RoleAdmin && req.NewUsername != "" {
|
||||
c.JSON(http.StatusForbidden, AccountResponse{
|
||||
Code: 403,
|
||||
Message: "无权修改用户名",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, AccountResponse{
|
||||
Code: 404,
|
||||
Message: "用户不存在",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.CurrentPassword)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, AccountResponse{
|
||||
Code: 400,
|
||||
Message: "当前密码错误",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.NewUsername != "" && len(req.NewUsername) < 3 {
|
||||
c.JSON(http.StatusBadRequest, AccountResponse{
|
||||
Code: 400,
|
||||
Message: "用户名长度不能小于3",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.NewUsername == "" && req.NewPassword == "" {
|
||||
c.JSON(http.StatusBadRequest, AccountResponse{
|
||||
Code: 400,
|
||||
Message: "请至少修改一项",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if err := tx.Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, AccountResponse{
|
||||
Code: 500,
|
||||
Message: "数据库操作失败: " + err.Error(),
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if req.NewUsername != "" {
|
||||
if isTouristUsername(req.NewUsername) {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusBadRequest, AccountResponse{
|
||||
Code: 400,
|
||||
Message: "游客保留用户名",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var existingUser models.User
|
||||
if err := db.Where("username = ? AND id != ?", req.NewUsername, userID).First(&existingUser).Error; err == nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusBadRequest, AccountResponse{
|
||||
Code: 400,
|
||||
Message: "用户名已存在",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Model(&user).Update("username", req.NewUsername).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, AccountResponse{
|
||||
Code: 500,
|
||||
Message: "用户名更新失败",
|
||||
Success: false,
|
||||
})
|
||||
if req.NewUsername != strings.TrimSpace(req.NewUsername) || len([]rune(req.NewUsername)) < 3 || isTouristUsername(req.NewUsername) {
|
||||
c.JSON(400, result.Error(400, "用户名格式无效或为保留名称"))
|
||||
return
|
||||
}
|
||||
updates["username"] = req.NewUsername
|
||||
}
|
||||
|
||||
if req.NewPassword != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if len(req.NewPassword) < 8 {
|
||||
c.JSON(400, result.Error(400, "新密码至少 8 个字符"))
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, AccountResponse{
|
||||
Code: 500,
|
||||
Message: "密码加密失败",
|
||||
Success: false,
|
||||
})
|
||||
c.JSON(500, result.Error(500, "密码处理失败"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Model(&user).Update("password", string(hashedPassword)).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, AccountResponse{
|
||||
Code: 500,
|
||||
Message: "密码更新失败",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
updates["password"] = hash
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(400, result.Error(400, "没有需要更新的内容"))
|
||||
return
|
||||
}
|
||||
db := database.GetDB().DB
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var latest models.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&latest, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, AccountResponse{
|
||||
Code: 500,
|
||||
Message: "数据库操作失败: " + err.Error(),
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 凭证变更后清除会话,要求重新登录
|
||||
session.Clear()
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, AccountResponse{
|
||||
Code: 500,
|
||||
Message: "会话失效失败: " + err.Error(),
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, AccountResponse{
|
||||
Code: 200,
|
||||
Message: "修改成功",
|
||||
Success: true,
|
||||
if latest.AuthVersion != persisted.AuthVersion {
|
||||
return middlewares.ErrSessionRevoked
|
||||
}
|
||||
if err := tx.Model(&latest).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authsecurity.RevokeUserSessions(tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&models.APICredential{}).Where("owner_id = ?", user.ID).Update("revoked", true).Error
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, middlewares.ErrSessionRevoked) {
|
||||
c.JSON(401, result.Error(401, "会话已撤销,请重新登录"))
|
||||
return
|
||||
}
|
||||
c.JSON(409, result.Error(409, "账户更新失败,请检查用户名是否已存在"))
|
||||
return
|
||||
}
|
||||
if err := middlewares.DeleteCurrentSession(c); err != nil {
|
||||
c.JSON(500, result.Error(500, "账户已更新,但浏览器会话清理失败"))
|
||||
return
|
||||
}
|
||||
c.JSON(200, result.Success("账户已更新,所有登录会话已撤销,请重新登录", nil))
|
||||
}
|
||||
|
||||
// isTouristUsername 判断是否为游客/保留用户名。
|
||||
// GetUUID is a public owner key, never identity proof. Only AuthMiddleware sets
|
||||
// username; guest UUID ownership additionally requires the negative server ID.
|
||||
func CurrentOwnerKey(c *gin.Context) string { return c.GetString("username") }
|
||||
func GetUUID(c *gin.Context) string { return CurrentOwnerKey(c) }
|
||||
|
||||
func isTouristUsername(username string) bool {
|
||||
return strings.HasPrefix(username, "guest_") || username == "guest" || uuidRegex.MatchString(username)
|
||||
return username == "guest" || strings.HasPrefix(username, "guest_") || uuidRegex.MatchString(username)
|
||||
}
|
||||
|
||||
// ClearAllSessions 清除当前请求的 Session。
|
||||
// Administrative global invalidation, not merely clearing the caller's cookie.
|
||||
func ClearAllSessions(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, AccountResponse{
|
||||
Code: 500,
|
||||
Message: "清除会话失败",
|
||||
Success: false,
|
||||
})
|
||||
if err := database.GetDB().DB.Transaction(authsecurity.RevokeAllSessions); err != nil {
|
||||
c.JSON(500, result.Error(500, "清除所有会话失败"))
|
||||
return
|
||||
}
|
||||
if err := middlewares.DeleteCurrentSession(c); err != nil {
|
||||
c.JSON(500, result.Error(500, "会话已撤销,浏览器 Cookie 清理失败"))
|
||||
return
|
||||
}
|
||||
c.JSON(200, result.Success("所有登录会话已清除;游客安全恢复凭据仍可用于重新登录", nil))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, AccountResponse{
|
||||
Code: 200,
|
||||
Message: "所有会话已清除",
|
||||
Success: true,
|
||||
// ListSessions exposes a one-way row handle, never the bearer cookie or payload.
|
||||
func ListSessions(c *gin.Context) {
|
||||
if c.GetString("auth_method") == "api_token" {
|
||||
c.JSON(403, result.Error(403, "需要登录会话"))
|
||||
return
|
||||
}
|
||||
user, ok := middlewares.GetCurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(401, result.Error(401, "未登录"))
|
||||
return
|
||||
}
|
||||
var rows []models.AuthSession
|
||||
var state models.AuthState
|
||||
db := database.GetDB().DB
|
||||
if err := db.First(&state, 1).Error; err != nil {
|
||||
internalFailure(c, "读取会话状态失败", err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
if err := db.Where("user_id = ? AND user_version = ? AND epoch = ? AND expires_at > ?", user.ID, user.AuthVersion, state.Epoch, time.Now()).Order("created_at desc").Limit(100).Find(&rows).Error; err != nil {
|
||||
c.JSON(500, result.Error(500, "获取会话失败"))
|
||||
return
|
||||
}
|
||||
current := authsecurity.Digest(sessions.Default(c).ID())
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, gin.H{"created_at": row.CreatedAt, "expires_at": row.ExpiresAt, "id": row.ID, "current": row.ID == current})
|
||||
}
|
||||
c.JSON(200, result.Success("ok", items))
|
||||
}
|
||||
|
||||
// RevokeOwnSessions supports all and others; both advance the credential epoch
|
||||
// so a concurrently executing login using the old password cannot resurrect it.
|
||||
func RevokeOwnSessions(c *gin.Context) {
|
||||
user, ok := middlewares.GetCurrentUser(c)
|
||||
if !ok || user.ID <= 0 || c.GetString("auth_method") == "api_token" {
|
||||
c.JSON(403, result.Error(403, "游客请使用退出登录"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Scope string `json:"scope" binding:"required,oneof=all others"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, result.Error(400, "scope 必须为 all 或 others"))
|
||||
return
|
||||
}
|
||||
err := database.GetDB().DB.Transaction(func(tx *gorm.DB) error {
|
||||
var latest models.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&latest, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if latest.AuthVersion != user.AuthVersion {
|
||||
return middlewares.ErrSessionRevoked
|
||||
}
|
||||
if err := authsecurity.RevokeUserSessions(tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
latest.AuthVersion++
|
||||
*user = latest
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetUUID 返回游客标识:优先会话中的 username(多为 UUID)。
|
||||
func GetUUID(c *gin.Context) string {
|
||||
return c.GetString("username")
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "撤销会话失败"))
|
||||
return
|
||||
}
|
||||
if req.Scope == "others" {
|
||||
sessions.Default(c).Set("_reissue", true)
|
||||
if _, err := saveUserSession(c, user); err != nil {
|
||||
c.JSON(500, result.Error(500, "会话已撤销,请重新登录"))
|
||||
return
|
||||
}
|
||||
} else if middlewares.DeleteCurrentSession(c) != nil {
|
||||
c.JSON(500, result.Error(500, "会话已撤销,Cookie 清理失败"))
|
||||
return
|
||||
}
|
||||
c.JSON(200, result.Success("会话已撤销", nil))
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"oneimg/backend/utils/result"
|
||||
)
|
||||
|
||||
type accountLoginHistoryItem struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// ListAccountLoginHistory exposes the current account's recent successful
|
||||
// sign-ins. Owner identifiers and session data never enter the response.
|
||||
func ListAccountLoginHistory(c *gin.Context) {
|
||||
var db *gorm.DB
|
||||
if current := database.GetDB(); current != nil {
|
||||
db = current.DB
|
||||
}
|
||||
listAccountLoginHistoryDB(c, db)
|
||||
}
|
||||
|
||||
func listAccountLoginHistoryDB(c *gin.Context, db *gorm.DB) {
|
||||
user, ok := middlewares.GetCurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, result.Error(http.StatusUnauthorized, "未登录"))
|
||||
return
|
||||
}
|
||||
if user.ID <= 0 || c.GetString("auth_method") == "api_token" {
|
||||
c.JSON(http.StatusForbidden, result.Error(http.StatusForbidden, "需要账户登录会话"))
|
||||
return
|
||||
}
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, result.Error(http.StatusServiceUnavailable, "登录历史服务暂不可用"))
|
||||
return
|
||||
}
|
||||
rows, err := authsecurity.LoginHistory(db.WithContext(c.Request.Context()), user.ID)
|
||||
if err != nil {
|
||||
internalFailure(c, "获取登录历史失败", err)
|
||||
return
|
||||
}
|
||||
items := make([]accountLoginHistoryItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, accountLoginHistoryItem{CreatedAt: row.CreatedAt, Method: row.Method})
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("ok", items))
|
||||
}
|
||||
|
||||
// Deliberately outside saveUserSession: renewal, password-change rotation and
|
||||
// revoke-others reissue use that helper but must not fabricate a new login.
|
||||
func recordAccountLogin(c *gin.Context, db *gorm.DB, userID int, method string) {
|
||||
if userID <= 0 {
|
||||
return
|
||||
}
|
||||
// A disconnected response must not erase a session already committed. Bound
|
||||
// the best-effort history write without relying on request cancellation.
|
||||
if db == nil {
|
||||
log.Printf("successful login history unavailable for account %d", userID)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := authsecurity.RecordSuccessfulLogin(db.Session(&gorm.Session{Logger: db.Logger.LogMode(logger.Silent)}).WithContext(ctx), userID, method); err != nil {
|
||||
// Do not log the error/SQL/request: providers and drivers may include data.
|
||||
log.Printf("successful login history unavailable for account %d", userID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"oneimg/backend/utils/result"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RevokeAccountSession(c *gin.Context) {
|
||||
user, ok := middlewares.GetCurrentUser(c)
|
||||
if !ok || user.ID <= 0 || c.GetString("auth_method") == "api_token" {
|
||||
c.JSON(403, result.Error(403, "需要已登录账户会话"))
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if len(id) != 64 || strings.Trim(id, "0123456789abcdef") != "" {
|
||||
c.JSON(400, result.Error(400, "会话ID无效"))
|
||||
return
|
||||
}
|
||||
current := authsecurity.Digest(sessions.Default(c).ID())
|
||||
if id == current {
|
||||
c.JSON(400, result.Error(400, "请使用退出登录结束当前会话"))
|
||||
return
|
||||
}
|
||||
db := database.GetDB().DB
|
||||
var target models.AuthSession
|
||||
if err := db.Where("id = ? AND user_id = ?", id, user.ID).First(&target).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(404, result.Error(404, "会话不存在"))
|
||||
return
|
||||
}
|
||||
internalFailure(c, "读取会话失败", err)
|
||||
return
|
||||
}
|
||||
if err := db.Where("id = ? AND user_id = ?", id, user.ID).Delete(&models.AuthSession{}).Error; err != nil {
|
||||
internalFailure(c, "撤销会话失败", err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, result.Success("会话已撤销", nil))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/settings"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegenerateAPIToken returns the generated secret exactly once. GET never calls
|
||||
// this handler; token material cannot be recovered from settings or its digest.
|
||||
func RegenerateAPIToken(c *gin.Context) {
|
||||
user,ok:=middlewares.GetCurrentUser(c)
|
||||
if !ok { c.JSON(401,result.Error(401,"未登录")); return }
|
||||
if user.ID<=0 || c.GetString("auth_method")=="api_token" || (user.ID!=models.SuperAdminID && !user.Permission.HasPermission("setting:api")) {
|
||||
c.JSON(403,result.Error(403,"无权生成 API 凭据")); return
|
||||
}
|
||||
raw,row,err:=authsecurity.NewAPICredential(database.GetDB().DB,user.ID)
|
||||
if err!=nil { c.JSON(500,result.Error(500,"API 凭据生成失败")); return }
|
||||
settings.Invalidate()
|
||||
c.Header("Cache-Control","private, no-store")
|
||||
c.JSON(http.StatusOK,result.Success("API 凭据仅显示一次,请妥善保存",gin.H{"api_token":raw,"expires_at":row.ExpiresAt,"scopes":row.Scopes}))
|
||||
}
|
||||
|
||||
func RenewSession(c *gin.Context) {
|
||||
user,ok:=middlewares.GetCurrentUser(c)
|
||||
if !ok || c.GetString("auth_method")=="api_token" { c.JSON(401,result.Error(401,"需要有效的登录会话")); return }
|
||||
if _,err:=saveUserSession(c,user); err!=nil { c.JSON(401,result.Error(401,"会话已撤销,请重新登录")); return }
|
||||
c.JSON(200,result.Success("会话已续期",nil))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package controllers
|
||||
|
||||
func bucketRemaining(capacity,usage uint64)uint64{
|
||||
if usage>=capacity {return 0}
|
||||
return capacity-usage
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"math"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/buckets"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AddBuckets 新增存储桶。
|
||||
func AddBuckets(c *gin.Context) {
|
||||
// 读取原始 body,便于后续 JSON 预处理(如 FTP 端口类型)
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "读取请求体失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 第一次解析:解析为动态map,提取name和type
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal(bodyBytes, ¶ms); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 基础参数校验
|
||||
if params["name"] == nil || params["type"] == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "name和type为必填参数"))
|
||||
return
|
||||
}
|
||||
|
||||
// 类型断言并校验
|
||||
name, okName := params["name"].(string)
|
||||
type_, okType := params["type"].(string)
|
||||
if !okName || !okType || name == "" || type_ == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "name和type必须为非空字符串"))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验type合法性
|
||||
validTypes := []string{"s3", "r2", "ftp", "webdav", "telegram"}
|
||||
if !sliceContains(validTypes, type_) {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "type参数错误,合法值:s3/r2/ftp/webdav/telegram"))
|
||||
return
|
||||
}
|
||||
if err := validateStorageProvider(type_, params); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var capacity float64
|
||||
var capacitybytes uint64
|
||||
if type_ != "telegram" {
|
||||
capacityStr, ok := params["capacity"].(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity必须是非负数值字符串"))
|
||||
return
|
||||
}
|
||||
// 将参数转化为int
|
||||
if capacityStr == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity为必填参数"))
|
||||
return
|
||||
}
|
||||
capacity, err = strconv.ParseFloat(capacityStr, 64)
|
||||
if err != nil || math.IsNaN(capacity) || math.IsInf(capacity, 0) || capacity < 0 || capacity > float64(^uint64(0))/(1024*1024*1024) {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity参数错误"))
|
||||
return
|
||||
}
|
||||
if capacity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity必须大于0"))
|
||||
return
|
||||
}
|
||||
// 保留两位小数
|
||||
capacity = keepTwoDecimal(capacity)
|
||||
// GB -> B
|
||||
capacitybytes = uint64(capacity * 1024 * 1024 * 1024)
|
||||
} else {
|
||||
capacitybytes = 0
|
||||
}
|
||||
|
||||
// 第二次解析:根据type解析为对应结构体
|
||||
var bucketConfig map[string]any
|
||||
switch type_ {
|
||||
case "s3":
|
||||
var s3Bucket models.S3Bucket
|
||||
if err := json.Unmarshal(bodyBytes, &s3Bucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "S3参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.S3BucketToMap(s3Bucket)
|
||||
case "r2":
|
||||
var r2Bucket models.R2Bucket
|
||||
if err := json.Unmarshal(bodyBytes, &r2Bucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "R2参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.R2BucketToMap(r2Bucket)
|
||||
case "ftp":
|
||||
var ftpBucket models.FTPBucket
|
||||
newBodyBytes, err := ftpBodyBytesPortToInt(bodyBytes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "FTP端口解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(newBodyBytes, &ftpBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "FTP参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.FTPBucketToMap(ftpBucket)
|
||||
case "webdav":
|
||||
var webdavBucket models.WebDavBucket
|
||||
if err := json.Unmarshal(bodyBytes, &webdavBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "WebDAV参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.WebDavBucketToMap(webdavBucket)
|
||||
case "telegram":
|
||||
var telegramBucket models.TelegramBucket
|
||||
if err := json.Unmarshal(bodyBytes, &telegramBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "Telegram参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.TelegramBucketToMap(telegramBucket)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "不支持的存储类型"))
|
||||
return
|
||||
}
|
||||
|
||||
err = ValidateBucketValues(bucketConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
encryptedConfig, err := secureconfig.EncryptBucketConfigValues(bucketConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "敏感配置加密失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 插入数据库
|
||||
db := database.GetDB()
|
||||
bucket := models.Buckets{
|
||||
Name: name,
|
||||
Type: type_,
|
||||
Capacity: capacitybytes,
|
||||
Config: encryptedConfig,
|
||||
Usage: 0,
|
||||
}
|
||||
if err := db.DB.Create(&bucket).Error; err != nil {
|
||||
// 判断是否已存在同名存储桶
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed: buckets.name") {
|
||||
c.JSON(http.StatusConflict, result.Error(409, "存储桶已存在"))
|
||||
return
|
||||
}
|
||||
internalFailure(c, "添加存储失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
responseBucket := bucket
|
||||
responseBucket.Config = secureconfig.MaskBucketConfigValues(bucket.Config)
|
||||
c.JSON(http.StatusOK, result.Success("添加成功", responseBucket))
|
||||
}
|
||||
|
||||
// UpdateBuckets 更新存储桶配置。
|
||||
func UpdateBuckets(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "id不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
if id == "1" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "默认存储桶不能编辑"))
|
||||
return
|
||||
}
|
||||
|
||||
// 查询存储桶信息
|
||||
db := database.GetDB()
|
||||
var bucket models.Buckets
|
||||
if err := db.DB.Where("id = ?", id).First(&bucket).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "存储桶不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
// 读取原始 body,便于后续 JSON 预处理(如 FTP 端口类型)
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "读取请求体失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 第一次解析:解析为动态map,提取name和type
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal(bodyBytes, ¶ms); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 类型断言并校验
|
||||
name, okName := params["name"].(string)
|
||||
type_, okType := params["type"].(string)
|
||||
if !okName || !okType || name == "" || type_ == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "name和type必须为非空字符串"))
|
||||
return
|
||||
}
|
||||
|
||||
var capacity float64
|
||||
var capacitybytes uint64
|
||||
if type_ != "telegram" {
|
||||
capacityStr, ok := params["capacity"].(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity必须是非负数值字符串"))
|
||||
return
|
||||
}
|
||||
// 将参数转化为int
|
||||
if capacityStr == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity为必填参数"))
|
||||
return
|
||||
}
|
||||
capacity, err = strconv.ParseFloat(capacityStr, 64)
|
||||
if err != nil || math.IsNaN(capacity) || math.IsInf(capacity, 0) || capacity < 0 || capacity > float64(^uint64(0))/(1024*1024*1024) {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity参数错误"))
|
||||
return
|
||||
}
|
||||
if capacity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity必须大于0"))
|
||||
return
|
||||
}
|
||||
// 保留两位小数
|
||||
capacity = keepTwoDecimal(capacity)
|
||||
// GB -> B
|
||||
capacitybytes = uint64(capacity * 1024 * 1024 * 1024)
|
||||
} else {
|
||||
capacitybytes = 0
|
||||
}
|
||||
|
||||
if type_ != bucket.Type {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "存储类型不可直接修改"))
|
||||
return
|
||||
}
|
||||
if err := validateStorageProvider(type_, params); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if capacitybytes < bucket.Usage && bucket.Type != "telegram" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "总容量不能小于已使用容量"))
|
||||
return
|
||||
}
|
||||
|
||||
// 第二次解析:根据type解析为对应结构体
|
||||
var bucketConfig map[string]any
|
||||
switch type_ {
|
||||
case "s3":
|
||||
var s3Bucket models.S3Bucket
|
||||
if err := json.Unmarshal(bodyBytes, &s3Bucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "S3参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.S3BucketToMap(s3Bucket)
|
||||
case "r2":
|
||||
var r2Bucket models.R2Bucket
|
||||
if err := json.Unmarshal(bodyBytes, &r2Bucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "R2参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.R2BucketToMap(r2Bucket)
|
||||
case "ftp":
|
||||
var ftpBucket models.FTPBucket
|
||||
newBodyBytes, err := ftpBodyBytesPortToInt(bodyBytes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "FTP端口解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(newBodyBytes, &ftpBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "FTP参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.FTPBucketToMap(ftpBucket)
|
||||
case "webdav":
|
||||
var webdavBucket models.WebDavBucket
|
||||
if err := json.Unmarshal(bodyBytes, &webdavBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "WebDAV参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.WebDavBucketToMap(webdavBucket)
|
||||
case "telegram":
|
||||
var telegramBucket models.TelegramBucket
|
||||
if err := json.Unmarshal(bodyBytes, &telegramBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "Telegram参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.TelegramBucketToMap(telegramBucket)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "不支持的存储类型"))
|
||||
return
|
||||
}
|
||||
|
||||
err = ValidateBucketValues(bucketConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Older clients omit new optional fields; preserve them unless explicitly cleared.
|
||||
if type_ == "r2" {
|
||||
for _, key := range []string{"r2_cdn_domain", "r2_cdn_mode"} {
|
||||
if _, supplied := params[key]; !supplied {
|
||||
delete(bucketConfig, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
mergedConfig, err := mergeBucketConfig(bucket.Config, bucketConfig)
|
||||
if err != nil {
|
||||
var requiredSensitiveKeys []string
|
||||
switch type_ {
|
||||
case "s3":
|
||||
requiredSensitiveKeys = []string{"s3_access_key", "s3_secret_key"}
|
||||
case "r2":
|
||||
requiredSensitiveKeys = []string{"r2_access_key", "r2_secret_key"}
|
||||
case "ftp":
|
||||
requiredSensitiveKeys = []string{"ftp_user", "ftp_pass"}
|
||||
case "webdav":
|
||||
requiredSensitiveKeys = []string{"webdav_user", "webdav_pass"}
|
||||
case "telegram":
|
||||
requiredSensitiveKeys = []string{"tg_bot_token"}
|
||||
}
|
||||
|
||||
canOverride := true
|
||||
for _, key := range requiredSensitiveKeys {
|
||||
val, exists := bucketConfig[key]
|
||||
if !exists || strings.TrimSpace(fmt.Sprintf("%v", val)) == "" {
|
||||
canOverride = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if canOverride {
|
||||
mergedConfig = bucketConfig
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "原有敏感配置解密失败,请重新填写完整的认证信息以覆盖"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateBucketValues(mergedConfig); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
encryptedConfig, err := secureconfig.EncryptBucketConfigValues(mergedConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "敏感配置加密失败"))
|
||||
return
|
||||
}
|
||||
|
||||
newBucket := models.Buckets{
|
||||
Name: name,
|
||||
Capacity: capacitybytes,
|
||||
Config: encryptedConfig,
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
if err := db.DB.Model(&bucket).Updates(newBucket).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed: buckets.name") {
|
||||
c.JSON(http.StatusConflict, result.Error(409, "存储桶已存在"))
|
||||
return
|
||||
}
|
||||
internalFailure(c, "更新存储失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
bucket.Name = newBucket.Name
|
||||
bucket.Capacity = newBucket.Capacity
|
||||
bucket.Config = secureconfig.MaskBucketConfigValues(newBucket.Config)
|
||||
c.JSON(http.StatusOK, result.Success("更新成功", bucket))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"log"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/settings"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpdateBucketEnabled 启用/禁用存储桶;禁用可逆,保留对象与同步记录。
|
||||
func UpdateBucketEnabled(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "存储源ID无效"))
|
||||
return
|
||||
}
|
||||
|
||||
var req updateBucketEnabledRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "启用状态参数无效"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var bucket models.Buckets
|
||||
if err := db.DB.First(&bucket, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "存储源不存在"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询存储源失败"))
|
||||
return
|
||||
}
|
||||
if bucket.Type == "default" && !*req.Enabled {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "本机存储源用于访问回退,不能停用"))
|
||||
return
|
||||
}
|
||||
|
||||
disabled := !*req.Enabled
|
||||
if err := db.DB.Model(&bucket).Update("disabled", disabled).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "更新存储源状态失败"))
|
||||
return
|
||||
}
|
||||
bucket.Disabled = disabled
|
||||
if *req.Enabled {
|
||||
services.WakeStorageSyncWorker()
|
||||
}
|
||||
|
||||
message := "存储源已启用"
|
||||
if disabled {
|
||||
message = "存储源已临时停用"
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success(message, gin.H{
|
||||
"id": bucket.Id,
|
||||
"enabled": !bucket.Disabled,
|
||||
"disabled": bucket.Disabled,
|
||||
}))
|
||||
}
|
||||
|
||||
// DeleteBuckets 删除存储桶;仅移除该源上的副本,保留其它源与主记录。
|
||||
func DeleteBuckets(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "存储桶ID无效"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var bucket models.Buckets
|
||||
if err := db.DB.First(&bucket, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "存储桶不存在"))
|
||||
return
|
||||
}
|
||||
if bucket.Type == "default" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "本机存储桶不能删除"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check the explicit guest target before cancelling tasks or deleting any
|
||||
// physical replica. Keep the existing system-default reset behavior below.
|
||||
var policy models.Settings
|
||||
if err := db.DB.Select("id", "guest_storage").First(&policy, 1).Error; err != nil {
|
||||
internalFailure(c, "获取游客存储策略失败", err)
|
||||
return
|
||||
}
|
||||
if policy.GuestStorage == id {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "该存储源是游客指定存储,请先修改游客存储分配后再删除"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := services.CancelDirectUploadsForBucket(ctx, bucket.Id); err != nil {
|
||||
var taskError *services.DirectUploadError
|
||||
if errors.As(err, &taskError) {
|
||||
c.JSON(taskError.Status, result.Error(taskError.Status, taskError.Message))
|
||||
} else {
|
||||
c.JSON(http.StatusServiceUnavailable, result.Error(503, "暂停存储源后台任务失败,请稍后重试删除"))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := services.DeleteBucketReplicas(ctx, bucket); err != nil {
|
||||
log.Printf("删除存储桶 %d 的文件副本失败:%v", id, err)
|
||||
c.JSON(http.StatusBadGateway, result.Error(502, "部分文件副本删除失败,存储源已保留"))
|
||||
return
|
||||
}
|
||||
|
||||
err = db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var primaryImages []models.Image
|
||||
if err := tx.Where("bucket_id = ?", id).Find(&primaryImages).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, image := range primaryImages {
|
||||
var replacement models.ImageStorage
|
||||
replacementErr := tx.Where(
|
||||
"image_id = ? AND bucket_id != ? AND status = ?",
|
||||
image.Id, id, models.ImageStorageStatusSuccess,
|
||||
).Order("bucket_id ASC").First(&replacement).Error
|
||||
if replacementErr == nil {
|
||||
if err := tx.Model(&image).Updates(map[string]any{
|
||||
"bucket_id": replacement.BucketID,
|
||||
"storage": replacement.Storage,
|
||||
"url": replacement.URL,
|
||||
"thumbnail": replacement.Thumbnail,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !errors.Is(replacementErr, gorm.ErrRecordNotFound) {
|
||||
return replacementErr
|
||||
}
|
||||
if err := tx.Where("image_id = ?", image.Id).Delete(&models.ImageToTags{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&image).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Where("bucket_id = ?", id).Delete(&models.ImageStorage{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.Image{}).Where("access_bucket_id = ?", id).Update("access_bucket_id", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var users []models.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
filtered := make([]int, 0, len(user.Permission.Buckets))
|
||||
for _, bucketID := range user.Permission.Buckets {
|
||||
if bucketID != id {
|
||||
filtered = append(filtered, bucketID)
|
||||
}
|
||||
}
|
||||
if len(filtered) != len(user.Permission.Buckets) {
|
||||
if err := tx.Model(&user).Update("permission", models.Permission{Codes: user.Permission.Codes, Buckets: filtered}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Delete(&models.Buckets{}, id).Error
|
||||
})
|
||||
if err != nil {
|
||||
internalFailure(c, "删除存储桶失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
setting, settingErr := settings.GetSettings()
|
||||
if settingErr != nil {
|
||||
log.Printf("获取默认存储桶失败:%v", settingErr)
|
||||
} else if setting.DefaultStorage == id {
|
||||
if err := db.DB.Model(&models.Settings{}).Where("default_storage = ?", id).Update("default_storage", 1).Error; err != nil {
|
||||
log.Printf("更新默认存储桶失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("删除成功", nil))
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
ftpclient "oneimg/backend/utils/ftp"
|
||||
"oneimg/backend/utils/result"
|
||||
s3client "oneimg/backend/utils/s3"
|
||||
"oneimg/backend/utils/safefetch"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
webdavclient "oneimg/backend/utils/webdav"
|
||||
)
|
||||
@@ -34,7 +35,7 @@ var bucketConnectionTestSlots = make(chan struct{}, 4)
|
||||
|
||||
var bucketConfigKeys = map[string][]string{
|
||||
"s3": {"s3_endpoint", "s3_access_key", "s3_secret_key", "s3_bucket"},
|
||||
"r2": {"r2_endpoint", "r2_access_key", "r2_secret_key", "r2_bucket"},
|
||||
"r2": {"r2_endpoint", "r2_access_key", "r2_secret_key", "r2_bucket", "r2_cdn_domain", "r2_cdn_mode"},
|
||||
"ftp": {"ftp_host", "ftp_port", "ftp_user", "ftp_pass"},
|
||||
"webdav": {"webdav_url", "webdav_user", "webdav_pass"},
|
||||
"telegram": {"tg_bot_token", "tg_receivers"},
|
||||
@@ -225,6 +226,9 @@ func validateBucketTestConfig(bucketType string, config map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
for _, key := range bucketConfigKeys[bucketType] {
|
||||
if key == "r2_cdn_domain" || key == "r2_cdn_mode" {
|
||||
continue
|
||||
}
|
||||
if key == "ftp_port" {
|
||||
if secureconfig.GetInt(config, key) < 1 {
|
||||
return errors.New("ftp_port 为必填项")
|
||||
@@ -235,7 +239,12 @@ func validateBucketTestConfig(bucketType string, config map[string]any) error {
|
||||
return fmt.Errorf("%s 为必填项", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if bucketType == "r2" {
|
||||
if _, _, err := utilsBuckets.R2CDNConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return validateStorageProvider(bucketType, config)
|
||||
}
|
||||
|
||||
func testBucketConnection(ctx context.Context, bucket models.Buckets) (string, error) {
|
||||
@@ -275,7 +284,7 @@ func testLocalStorage() (string, error) {
|
||||
return "本地目录可读写", nil
|
||||
}
|
||||
|
||||
func testS3CompatibleStorage(ctx context.Context, bucket models.Buckets) (string, error) {
|
||||
func testS3CompatibleStorage(ctx context.Context, bucket models.Buckets) (detail string, resultErr error) {
|
||||
client, err := s3client.NewS3Client(models.Settings{}, bucket)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -287,14 +296,48 @@ func testS3CompatibleStorage(ctx context.Context, bucket models.Buckets) (string
|
||||
bucketName = utilsBuckets.ConvertToR2Bucket(bucket.Config).R2Bucket
|
||||
}
|
||||
key := ".oneimg-connection-test/" + uuid.NewString() + ".txt"
|
||||
content := []byte("oneimg storage connection test")
|
||||
content := []byte("oneimg storage connection test " + uuid.NewString())
|
||||
if _, err := client.PutObject(ctx, bucketName, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{}); err != nil {
|
||||
return "", fmt.Errorf("测试对象写入失败: %s", translateS3Error(err))
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := client.RemoveObject(cleanupCtx, bucketName, key, minio.RemoveObjectOptions{}); err != nil {
|
||||
return "", fmt.Errorf("写入成功,但测试对象清理失败: %w", err)
|
||||
defer func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := client.RemoveObject(cleanupCtx, bucketName, key, minio.RemoveObjectOptions{}); err != nil {
|
||||
resultErr = errors.Join(resultErr, errors.New("测试对象清理失败,请检查存储删除权限"))
|
||||
}
|
||||
}()
|
||||
if bucket.Type == "r2" {
|
||||
domain, mode, err := utilsBuckets.R2CDNConfig(bucket.Config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if domain != "" {
|
||||
target, err := utilsBuckets.R2CDNObjectURL(domain, key)
|
||||
if mode == utilsBuckets.R2CDNSignedProxy {
|
||||
signed, signErr := client.PresignedGetObject(ctx, bucketName, key, time.Minute, nil)
|
||||
if signErr != nil {
|
||||
return "", errors.New("CDN 测试签名生成失败")
|
||||
}
|
||||
target, err = utilsBuckets.R2CDNSignedURL(domain, signed)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
response, err := safefetch.GetNoRedirect(ctx, target, 10*time.Second)
|
||||
if err != nil {
|
||||
return "", errors.New("存储可写,但 CDN 访问失败,请检查域名、证书及回源配置")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("存储可写,但 CDN 返回 HTTP %d,请检查访问方式和回源配置", response.StatusCode)
|
||||
}
|
||||
body, err := safefetch.ReadLimited(response.Body, 4096)
|
||||
if err != nil || !bytes.Equal(body, content) {
|
||||
return "", errors.New("CDN 返回的内容与测试对象不一致,请检查路径前缀及回源桶")
|
||||
}
|
||||
return "已验证对象写入、CDN 读取与删除权限", nil
|
||||
}
|
||||
}
|
||||
return "已验证对象写入与删除权限", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"oneimg/backend/config"
|
||||
)
|
||||
|
||||
type DiskUsageDetail struct {
|
||||
TotalBytes uint64 `json:"-"` // 总容量(字节,不返回前端)
|
||||
UsedBytes uint64 `json:"-"` // 已用容量(字节,不返回前端)
|
||||
FreeBytes uint64 `json:"-"` // 可用容量(字节,不返回前端)
|
||||
Total string `json:"total"` // 总容量
|
||||
Used string `json:"used"` // 已用容量
|
||||
Free string `json:"free"` // 可用容量
|
||||
Percent float64 `json:"percent"` // 使用率
|
||||
}
|
||||
|
||||
// keepTwoDecimal 将 float64 四舍五入到两位小数。
|
||||
func keepTwoDecimal(num float64) float64 {
|
||||
return float64(int(num*100+0.5)) / 100
|
||||
}
|
||||
|
||||
// 辅助函数:获取磁盘使用情况
|
||||
func getDiskUsage() (diskInfo DiskUsageDetail, err error) {
|
||||
// Report the actual upload mount, never the container's working directory.
|
||||
usage, err := disk.Usage(config.UploadRoot())
|
||||
if err != nil {
|
||||
return DiskUsageDetail{}, err
|
||||
}
|
||||
|
||||
return DiskUsageDetail{
|
||||
TotalBytes: usage.Total,
|
||||
UsedBytes: usage.Used,
|
||||
FreeBytes: usage.Free,
|
||||
Total: formatSize(usage.Total),
|
||||
Used: formatSize(usage.Used),
|
||||
Free: formatSize(usage.Free),
|
||||
Percent: usage.UsedPercent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 辅助函数:将字节单位转换为易读的格式
|
||||
func formatSize(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"oneimg/backend/utils/buckets"
|
||||
storageS3 "oneimg/backend/utils/s3"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 辅助函数:检查是否包含某个元素
|
||||
func sliceContains(slice []string, target string) bool {
|
||||
return slices.Contains(slice, target)
|
||||
}
|
||||
|
||||
// 辅助函数:校验空值
|
||||
// Keep provider-specific behavior explicit instead of hiding R2 under S3.
|
||||
func validateStorageProvider(storageType string, params map[string]any) error {
|
||||
if storageType == "s3" {
|
||||
endpoint, _ := params["s3_endpoint"].(string)
|
||||
if storageS3.IsR2Endpoint(endpoint) {
|
||||
return errors.New("Cloudflare R2 请使用独立的 Cloudflare R2 存储类型")
|
||||
}
|
||||
}
|
||||
if storageType == "r2" {
|
||||
endpoint, _ := params["r2_endpoint"].(string)
|
||||
if !storageS3.IsR2Endpoint(endpoint) || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(endpoint)), "https://") {
|
||||
return errors.New("请输入 Cloudflare R2 的 HTTPS S3 API 地址")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateBucketValues(bucketMap map[string]any) (err error) {
|
||||
if domain, ok := bucketMap["r2_cdn_domain"]; ok {
|
||||
value, valid := domain.(string)
|
||||
mode, _ := bucketMap["r2_cdn_mode"].(string)
|
||||
if !valid {
|
||||
return errors.New("CDN 地址格式无效")
|
||||
}
|
||||
if _, _, err := buckets.NormalizeR2CDN(value, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for key, val := range bucketMap {
|
||||
if key == "r2_cdn_domain" || key == "r2_cdn_mode" {
|
||||
continue
|
||||
}
|
||||
if secureconfig.IsBucketSensitiveKey(key) {
|
||||
continue
|
||||
}
|
||||
if val == "" {
|
||||
return fmt.Errorf("%s 为必填项", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeBucketConfig(existingConfig map[string]any, incomingConfig map[string]any) (map[string]any, error) {
|
||||
decryptedExisting, err := secureconfig.DecryptBucketConfigValues(existingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
merged := make(map[string]any, len(decryptedExisting)+len(incomingConfig))
|
||||
for key, value := range decryptedExisting {
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
for key, value := range incomingConfig {
|
||||
if secureconfig.IsBucketSensitiveKey(key) && strings.TrimSpace(fmt.Sprintf("%v", value)) == "" {
|
||||
continue
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// 工具函数,将FTP端口为Int类型
|
||||
func ftpBodyBytesPortToInt(bodyBytes []byte) ([]byte, error) {
|
||||
var tempMap map[string]any
|
||||
if err := json.Unmarshal(bodyBytes, &tempMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if portStr, ok := tempMap["ftp_port"].(string); ok {
|
||||
portNum, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return nil, errors.New("ftp_port必须为数字")
|
||||
}
|
||||
tempMap["ftp_port"] = portNum
|
||||
}
|
||||
|
||||
newBody, err := json.Marshal(tempMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newBody, nil
|
||||
}
|
||||
+15
-630
@@ -1,45 +1,14 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/buckets"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
"oneimg/backend/utils/settings"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DiskUsageDetail struct {
|
||||
TotalBytes uint64 `json:"-"` // 总容量(字节,不返回前端)
|
||||
UsedBytes uint64 `json:"-"` // 已用容量(字节,不返回前端)
|
||||
FreeBytes uint64 `json:"-"` // 可用容量(字节,不返回前端)
|
||||
Total string `json:"total"` // 总容量
|
||||
Used string `json:"used"` // 已用容量
|
||||
Free string `json:"free"` // 可用容量
|
||||
Percent float64 `json:"percent"` // 使用率
|
||||
}
|
||||
|
||||
// keepTwoDecimal 将 float64 四舍五入到两位小数。
|
||||
func keepTwoDecimal(num float64) float64 {
|
||||
return float64(int(num*100+0.5)) / 100
|
||||
}
|
||||
|
||||
// GetBuckets 获取全部存储桶及容量使用情况(敏感配置已脱敏)。
|
||||
func GetBuckets(c *gin.Context) {
|
||||
var buckets []models.Buckets
|
||||
@@ -52,17 +21,19 @@ func GetBuckets(c *gin.Context) {
|
||||
// 返回结构体
|
||||
type BucketResponse struct {
|
||||
models.Buckets
|
||||
UsageReadable string `json:"usage_readable"` // 已用容量
|
||||
TotalReadable string `json:"total_readable"` // 总容量
|
||||
UsagePercent float64 `json:"usage_percent"` // 使用率(保留两位小数)
|
||||
UsageFree string `json:"usage_free"` // 可用容量
|
||||
UsageReadable string `json:"usage_readable"` // 已用容量
|
||||
TotalReadable string `json:"total_readable"` // 总容量
|
||||
UsagePercent float64 `json:"usage_percent"` // 使用率(保留两位小数)
|
||||
UsageFree string `json:"usage_free"` // 可用容量
|
||||
AccountedUsageReadable string `json:"accounted_usage_readable"` // 已登记图床副本,不混同整盘占用
|
||||
DiskUsage *DiskUsageDetail `json:"disk_usage,omitempty"`
|
||||
}
|
||||
var bucketRes []BucketResponse
|
||||
|
||||
for _, bucket := range buckets {
|
||||
maskedConfig := secureconfig.MaskBucketConfigValues(bucket.Config)
|
||||
bucket.Config = maskedConfig
|
||||
res := BucketResponse{Buckets: bucket}
|
||||
res := BucketResponse{Buckets: bucket, AccountedUsageReadable: formatSize(bucket.Usage)}
|
||||
// 根据存储类型计算/转换容量和使用量
|
||||
switch bucket.Type {
|
||||
case "default": // 本地磁盘
|
||||
@@ -72,21 +43,21 @@ func GetBuckets(c *gin.Context) {
|
||||
bucketRes = append(bucketRes, res)
|
||||
continue
|
||||
}
|
||||
db.DB.Model(&models.Buckets{}).Where("id = ?", bucket.Id).Update("usage", diskInfo.UsedBytes)
|
||||
res.DiskUsage = &diskInfo
|
||||
res.TotalReadable = diskInfo.Total
|
||||
res.UsageReadable = diskInfo.Used
|
||||
res.UsageFree = diskInfo.Free
|
||||
res.UsagePercent = keepTwoDecimal(diskInfo.Percent) // 保留两位小数
|
||||
case "s3", "r2", "ftp", "webdav":
|
||||
// 计算使用量
|
||||
res.TotalReadable = formatSize(bucket.Capacity)
|
||||
res.UsageReadable = formatSize(bucket.Usage)
|
||||
res.UsageFree = formatSize(bucket.Capacity - bucket.Usage)
|
||||
usagePercent := keepTwoDecimal(float64(bucket.Usage) / float64(bucket.Capacity) * 100)
|
||||
if usagePercent < 0 {
|
||||
if bucket.Capacity == 0 {
|
||||
res.TotalReadable = "无限"
|
||||
res.UsageFree = "无限"
|
||||
res.UsagePercent = 0
|
||||
} else {
|
||||
res.UsagePercent = usagePercent
|
||||
res.TotalReadable = formatSize(bucket.Capacity)
|
||||
res.UsageFree = formatSize(bucketRemaining(bucket.Capacity, bucket.Usage))
|
||||
res.UsagePercent = keepTwoDecimal(min(100, float64(bucket.Usage)/float64(bucket.Capacity)*100))
|
||||
}
|
||||
case "telegram": // Telegram 不限容量
|
||||
res.TotalReadable = "无限"
|
||||
@@ -129,592 +100,6 @@ func GetBucketsList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result.Success("ok", bucketRes))
|
||||
}
|
||||
|
||||
// AddBuckets 新增存储桶。
|
||||
func AddBuckets(c *gin.Context) {
|
||||
// 读取原始 body,便于后续 JSON 预处理(如 FTP 端口类型)
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "读取请求体失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 第一次解析:解析为动态map,提取name和type
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal(bodyBytes, ¶ms); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 基础参数校验
|
||||
if params["name"] == nil || params["type"] == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "name和type为必填参数"))
|
||||
return
|
||||
}
|
||||
|
||||
// 类型断言并校验
|
||||
name, okName := params["name"].(string)
|
||||
type_, okType := params["type"].(string)
|
||||
if !okName || !okType || name == "" || type_ == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "name和type必须为非空字符串"))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验type合法性
|
||||
validTypes := []string{"s3", "r2", "ftp", "webdav", "telegram"}
|
||||
if !sliceContains(validTypes, type_) {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "type参数错误,合法值:s3/r2/ftp/webdav/telegram"))
|
||||
return
|
||||
}
|
||||
|
||||
var capacity float64
|
||||
var capacitybytes uint64
|
||||
if type_ != "telegram" {
|
||||
capacityStr := params["capacity"].(string)
|
||||
// 将参数转化为int
|
||||
if capacityStr == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity为必填参数"))
|
||||
return
|
||||
}
|
||||
capacity, err = strconv.ParseFloat(capacityStr, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity参数错误"))
|
||||
return
|
||||
}
|
||||
if capacity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity必须大于0"))
|
||||
return
|
||||
}
|
||||
// 保留两位小数
|
||||
capacity = keepTwoDecimal(capacity)
|
||||
// GB -> B
|
||||
capacitybytes = uint64(capacity * 1024 * 1024 * 1024)
|
||||
} else {
|
||||
capacitybytes = 0
|
||||
}
|
||||
|
||||
// 第二次解析:根据type解析为对应结构体
|
||||
var bucketConfig map[string]any
|
||||
switch type_ {
|
||||
case "s3":
|
||||
var s3Bucket models.S3Bucket
|
||||
if err := json.Unmarshal(bodyBytes, &s3Bucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "S3参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.S3BucketToMap(s3Bucket)
|
||||
case "r2":
|
||||
var r2Bucket models.R2Bucket
|
||||
if err := json.Unmarshal(bodyBytes, &r2Bucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "R2参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.R2BucketToMap(r2Bucket)
|
||||
case "ftp":
|
||||
var ftpBucket models.FTPBucket
|
||||
newBodyBytes, err := ftpBodyBytesPortToInt(bodyBytes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "FTP端口解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(newBodyBytes, &ftpBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "FTP参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.FTPBucketToMap(ftpBucket)
|
||||
case "webdav":
|
||||
var webdavBucket models.WebDavBucket
|
||||
if err := json.Unmarshal(bodyBytes, &webdavBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "WebDAV参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.WebDavBucketToMap(webdavBucket)
|
||||
case "telegram":
|
||||
var telegramBucket models.TelegramBucket
|
||||
if err := json.Unmarshal(bodyBytes, &telegramBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "Telegram参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.TelegramBucketToMap(telegramBucket)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "不支持的存储类型"))
|
||||
return
|
||||
}
|
||||
|
||||
err = ValidateBucketValues(bucketConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
encryptedConfig, err := secureconfig.EncryptBucketConfigValues(bucketConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "敏感配置加密失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 插入数据库
|
||||
db := database.GetDB()
|
||||
bucket := models.Buckets{
|
||||
Name: name,
|
||||
Type: type_,
|
||||
Capacity: capacitybytes,
|
||||
Config: encryptedConfig,
|
||||
Usage: 0,
|
||||
}
|
||||
if err := db.DB.Create(&bucket).Error; err != nil {
|
||||
// 判断是否已存在同名存储桶
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed: buckets.name") {
|
||||
c.JSON(http.StatusConflict, result.Error(409, "存储桶已存在"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "添加存储失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
responseBucket := bucket
|
||||
responseBucket.Config = secureconfig.MaskBucketConfigValues(bucket.Config)
|
||||
c.JSON(http.StatusOK, result.Success("添加成功", responseBucket))
|
||||
}
|
||||
|
||||
// UpdateBuckets 更新存储桶配置。
|
||||
func UpdateBuckets(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "id不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
if id == "1" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "默认存储桶不能编辑"))
|
||||
return
|
||||
}
|
||||
|
||||
// 查询存储桶信息
|
||||
db := database.GetDB()
|
||||
var bucket models.Buckets
|
||||
if err := db.DB.Where("id = ?", id).First(&bucket).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "存储桶不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
// 读取原始 body,便于后续 JSON 预处理(如 FTP 端口类型)
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "读取请求体失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 第一次解析:解析为动态map,提取name和type
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal(bodyBytes, ¶ms); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 类型断言并校验
|
||||
name, okName := params["name"].(string)
|
||||
type_, okType := params["type"].(string)
|
||||
if !okName || !okType || name == "" || type_ == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "name和type必须为非空字符串"))
|
||||
return
|
||||
}
|
||||
|
||||
var capacity float64
|
||||
var capacitybytes uint64
|
||||
if type_ != "telegram" {
|
||||
capacityStr := params["capacity"].(string)
|
||||
// 将参数转化为int
|
||||
if capacityStr == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity为必填参数"))
|
||||
return
|
||||
}
|
||||
capacity, err = strconv.ParseFloat(capacityStr, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity参数错误"))
|
||||
return
|
||||
}
|
||||
if capacity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "capacity必须大于0"))
|
||||
return
|
||||
}
|
||||
// 保留两位小数
|
||||
capacity = keepTwoDecimal(capacity)
|
||||
// GB -> B
|
||||
capacitybytes = uint64(capacity * 1024 * 1024 * 1024)
|
||||
} else {
|
||||
capacitybytes = 0
|
||||
}
|
||||
|
||||
if capacitybytes < bucket.Usage && bucket.Type != "telegram" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "总容量不能小于已使用容量"))
|
||||
return
|
||||
}
|
||||
|
||||
// 第二次解析:根据type解析为对应结构体
|
||||
var bucketConfig map[string]any
|
||||
switch type_ {
|
||||
case "s3":
|
||||
var s3Bucket models.S3Bucket
|
||||
if err := json.Unmarshal(bodyBytes, &s3Bucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "S3参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.S3BucketToMap(s3Bucket)
|
||||
case "r2":
|
||||
var r2Bucket models.R2Bucket
|
||||
if err := json.Unmarshal(bodyBytes, &r2Bucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "R2参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.R2BucketToMap(r2Bucket)
|
||||
case "ftp":
|
||||
var ftpBucket models.FTPBucket
|
||||
newBodyBytes, err := ftpBodyBytesPortToInt(bodyBytes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "FTP端口解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(newBodyBytes, &ftpBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "FTP参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.FTPBucketToMap(ftpBucket)
|
||||
case "webdav":
|
||||
var webdavBucket models.WebDavBucket
|
||||
if err := json.Unmarshal(bodyBytes, &webdavBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "WebDAV参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.WebDavBucketToMap(webdavBucket)
|
||||
case "telegram":
|
||||
var telegramBucket models.TelegramBucket
|
||||
if err := json.Unmarshal(bodyBytes, &telegramBucket); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "Telegram参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
bucketConfig = buckets.TelegramBucketToMap(telegramBucket)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "不支持的存储类型"))
|
||||
return
|
||||
}
|
||||
|
||||
err = ValidateBucketValues(bucketConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
mergedConfig, err := mergeBucketConfig(bucket.Config, bucketConfig)
|
||||
if err != nil {
|
||||
var requiredSensitiveKeys []string
|
||||
switch type_ {
|
||||
case "s3":
|
||||
requiredSensitiveKeys = []string{"s3_access_key", "s3_secret_key"}
|
||||
case "r2":
|
||||
requiredSensitiveKeys = []string{"r2_access_key", "r2_secret_key"}
|
||||
case "ftp":
|
||||
requiredSensitiveKeys = []string{"ftp_user", "ftp_pass"}
|
||||
case "webdav":
|
||||
requiredSensitiveKeys = []string{"webdav_user", "webdav_pass"}
|
||||
case "telegram":
|
||||
requiredSensitiveKeys = []string{"tg_bot_token"}
|
||||
}
|
||||
|
||||
canOverride := true
|
||||
for _, key := range requiredSensitiveKeys {
|
||||
val, exists := bucketConfig[key]
|
||||
if !exists || strings.TrimSpace(fmt.Sprintf("%v", val)) == "" {
|
||||
canOverride = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if canOverride {
|
||||
mergedConfig = bucketConfig
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "原有敏感配置解密失败,请重新填写完整的认证信息以覆盖"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
encryptedConfig, err := secureconfig.EncryptBucketConfigValues(mergedConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "敏感配置加密失败"))
|
||||
return
|
||||
}
|
||||
|
||||
newBucket := models.Buckets{
|
||||
Name: name,
|
||||
Capacity: capacitybytes,
|
||||
Config: encryptedConfig,
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
if err := db.DB.Model(&bucket).Updates(newBucket).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed: buckets.name") {
|
||||
c.JSON(http.StatusConflict, result.Error(409, "存储桶已存在"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "更新存储失败"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
bucket.Name = newBucket.Name
|
||||
bucket.Capacity = newBucket.Capacity
|
||||
bucket.Config = secureconfig.MaskBucketConfigValues(newBucket.Config)
|
||||
c.JSON(http.StatusOK, result.Success("更新成功", bucket))
|
||||
}
|
||||
|
||||
type updateBucketEnabledRequest struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateBucketEnabled 启用/禁用存储桶;禁用可逆,保留对象与同步记录。
|
||||
func UpdateBucketEnabled(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "存储源ID无效"))
|
||||
return
|
||||
}
|
||||
|
||||
var req updateBucketEnabledRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "启用状态参数无效"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var bucket models.Buckets
|
||||
if err := db.DB.First(&bucket, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "存储源不存在"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询存储源失败"))
|
||||
return
|
||||
}
|
||||
if bucket.Type == "default" && !*req.Enabled {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "本机存储源用于访问回退,不能停用"))
|
||||
return
|
||||
}
|
||||
|
||||
disabled := !*req.Enabled
|
||||
if err := db.DB.Model(&bucket).Update("disabled", disabled).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "更新存储源状态失败"))
|
||||
return
|
||||
}
|
||||
bucket.Disabled = disabled
|
||||
if *req.Enabled {
|
||||
services.WakeStorageSyncWorker()
|
||||
}
|
||||
|
||||
message := "存储源已启用"
|
||||
if disabled {
|
||||
message = "存储源已临时停用"
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success(message, gin.H{
|
||||
"id": bucket.Id,
|
||||
"enabled": !bucket.Disabled,
|
||||
"disabled": bucket.Disabled,
|
||||
}))
|
||||
}
|
||||
|
||||
// DeleteBuckets 删除存储桶;仅移除该源上的副本,保留其它源与主记录。
|
||||
func DeleteBuckets(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "存储桶ID无效"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var bucket models.Buckets
|
||||
if err := db.DB.First(&bucket, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "存储桶不存在"))
|
||||
return
|
||||
}
|
||||
if bucket.Type == "default" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "本机存储桶不能删除"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := services.DeleteBucketReplicas(ctx, bucket); err != nil {
|
||||
log.Printf("删除存储桶 %d 的文件副本失败:%v", id, err)
|
||||
c.JSON(http.StatusBadGateway, result.Error(502, "部分文件副本删除失败,存储源已保留"))
|
||||
return
|
||||
}
|
||||
|
||||
err = db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var primaryImages []models.Image
|
||||
if err := tx.Where("bucket_id = ?", id).Find(&primaryImages).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, image := range primaryImages {
|
||||
var replacement models.ImageStorage
|
||||
replacementErr := tx.Where(
|
||||
"image_id = ? AND bucket_id != ? AND status = ?",
|
||||
image.Id, id, models.ImageStorageStatusSuccess,
|
||||
).Order("bucket_id ASC").First(&replacement).Error
|
||||
if replacementErr == nil {
|
||||
if err := tx.Model(&image).Updates(map[string]any{
|
||||
"bucket_id": replacement.BucketID,
|
||||
"storage": replacement.Storage,
|
||||
"url": replacement.URL,
|
||||
"thumbnail": replacement.Thumbnail,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !errors.Is(replacementErr, gorm.ErrRecordNotFound) {
|
||||
return replacementErr
|
||||
}
|
||||
if err := tx.Where("image_id = ?", image.Id).Delete(&models.ImageToTags{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&image).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Where("bucket_id = ?", id).Delete(&models.ImageStorage{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.Image{}).Where("access_bucket_id = ?", id).Update("access_bucket_id", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var users []models.User
|
||||
if err := tx.Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
filtered := make([]int, 0, len(user.Permission.Buckets))
|
||||
for _, bucketID := range user.Permission.Buckets {
|
||||
if bucketID != id {
|
||||
filtered = append(filtered, bucketID)
|
||||
}
|
||||
}
|
||||
if len(filtered) != len(user.Permission.Buckets) {
|
||||
if err := tx.Model(&user).Update("permission", models.Permission{Buckets: filtered}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Delete(&models.Buckets{}, id).Error
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "删除存储桶失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
setting, settingErr := settings.GetSettings()
|
||||
if settingErr != nil {
|
||||
log.Printf("获取默认存储桶失败:%v", settingErr)
|
||||
} else if setting.DefaultStorage == id {
|
||||
if err := db.DB.Model(&models.Settings{}).Where("default_storage = ?", id).Update("default_storage", 1).Error; err != nil {
|
||||
log.Printf("更新默认存储桶失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("删除成功", nil))
|
||||
}
|
||||
|
||||
// 辅助函数:检查是否包含某个元素
|
||||
func sliceContains(slice []string, target string) bool {
|
||||
return slices.Contains(slice, target)
|
||||
}
|
||||
|
||||
// 辅助函数:获取磁盘使用情况
|
||||
func getDiskUsage() (diskInfo DiskUsageDetail, err error) {
|
||||
path, err := os.Getwd()
|
||||
if err != nil {
|
||||
return DiskUsageDetail{}, err
|
||||
}
|
||||
usage, err := disk.Usage(path)
|
||||
if err != nil {
|
||||
return DiskUsageDetail{}, err
|
||||
}
|
||||
|
||||
return DiskUsageDetail{
|
||||
TotalBytes: usage.Total,
|
||||
UsedBytes: usage.Used,
|
||||
FreeBytes: usage.Free,
|
||||
Total: formatSize(usage.Total),
|
||||
Used: formatSize(usage.Used),
|
||||
Free: formatSize(usage.Free),
|
||||
Percent: usage.UsedPercent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 辅助函数:将字节单位转换为易读的格式
|
||||
func formatSize(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// 辅助函数:校验空值
|
||||
func ValidateBucketValues(bucketMap map[string]any) (err error) {
|
||||
for key, val := range bucketMap {
|
||||
if secureconfig.IsBucketSensitiveKey(key) {
|
||||
continue
|
||||
}
|
||||
if val == "" {
|
||||
return fmt.Errorf("%s 为必填项", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeBucketConfig(existingConfig map[string]any, incomingConfig map[string]any) (map[string]any, error) {
|
||||
decryptedExisting, err := secureconfig.DecryptBucketConfigValues(existingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
merged := make(map[string]any, len(decryptedExisting)+len(incomingConfig))
|
||||
for key, value := range decryptedExisting {
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
for key, value := range incomingConfig {
|
||||
if secureconfig.IsBucketSensitiveKey(key) && strings.TrimSpace(fmt.Sprintf("%v", value)) == "" {
|
||||
continue
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// 工具函数,将FTP端口为Int类型
|
||||
func ftpBodyBytesPortToInt(bodyBytes []byte) ([]byte, error) {
|
||||
var tempMap map[string]any
|
||||
if err := json.Unmarshal(bodyBytes, &tempMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if portStr, ok := tempMap["ftp_port"].(string); ok {
|
||||
portNum, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return nil, errors.New("ftp_port必须为数字")
|
||||
}
|
||||
tempMap["ftp_port"] = portNum
|
||||
}
|
||||
|
||||
newBody, err := json.Marshal(tempMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newBody, nil
|
||||
}
|
||||
@@ -2,29 +2,19 @@ package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/buckets"
|
||||
"oneimg/backend/utils/ftp"
|
||||
"oneimg/backend/utils/md5"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/s3"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/telegram"
|
||||
"oneimg/backend/utils/webdav"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -57,33 +47,23 @@ func DeleteImage(c *gin.Context) {
|
||||
deleteCtx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := services.CancelDirectUploadsForImage(deleteCtx, image.Id); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, result.Error(503, "暂停图片后台任务失败,请稍后重试删除"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DeleteImageReplicas(deleteCtx, image); err != nil {
|
||||
log.Printf("删除图片 %d 的存储副本失败:%v", image.Id, err)
|
||||
c.JSON(http.StatusBadGateway, result.Error(502, "部分存储源删除失败,文件记录已保留,可稍后重试"))
|
||||
return
|
||||
}
|
||||
|
||||
fileSize := uint64(image.FileSize)
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var storageList []models.ImageStorage
|
||||
if err := tx.Where("image_id = ?", image.Id).Find(&storageList).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, storage := range storageList {
|
||||
usageColumn := database.UsageColumn(tx)
|
||||
err := tx.Model(&models.Buckets{}).
|
||||
Where("id = ?", storage.BucketID).
|
||||
UpdateColumn("usage", gorm.Expr("CASE WHEN "+usageColumn+" >= ? THEN "+usageColumn+" - ? ELSE 0 END", fileSize, fileSize)).Error
|
||||
if err != nil {
|
||||
log.Printf("Bucket %d 扣减容量失败 size=%d err=%v", storage.BucketID, fileSize, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Where("image_id = ?", image.Id).Delete(&models.ImageStorage{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Replica cleanup owns per-artifact quota release. Never charge or release
|
||||
// canonical file_size a second time here.
|
||||
var remaining int64
|
||||
if err := tx.Model(&models.ImageStorage{}).Where("image_id = ?",image.Id).Count(&remaining).Error; err != nil { return err }
|
||||
if remaining != 0 { return fmt.Errorf("replica cleanup incomplete") }
|
||||
if err := tx.Where("image_id = ?", image.Id).Delete(&models.ImageToTags{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -105,198 +85,13 @@ func DeleteImage(c *gin.Context) {
|
||||
// CheckImageAccessPermission 校验当前用户是否可操作目标图片。
|
||||
// 规则:超管图片仅超管可动;本人或超管放行;否则需 requiredPerm;游客用 UUID+MD5。
|
||||
func CheckImageAccessPermission(c *gin.Context, image models.Image, requiredPerm string) bool {
|
||||
userId := c.GetInt("user_id")
|
||||
userRole := c.GetInt("user_role")
|
||||
|
||||
if image.UserId == models.SuperAdminID && userId != models.SuperAdminID {
|
||||
return false
|
||||
}
|
||||
if (userId > 0 && userId == image.UserId) || userId == models.SuperAdminID {
|
||||
return true
|
||||
}
|
||||
if userId != image.UserId && userRole != models.RoleAdmin {
|
||||
return false
|
||||
}
|
||||
|
||||
userID, role := c.GetInt("user_id"), c.GetInt("user_role")
|
||||
// Guest IDs can never take the real-user/admin fast path, even for legacy
|
||||
// positive IDs or a chosen fingerprint matching a registered user.
|
||||
if role == models.RoleGuest { return guestOwnsImage(c,image) }
|
||||
if userID <= 0 || (role != models.RoleUser && role != models.RoleAdmin) { return false }
|
||||
if image.UserId == models.SuperAdminID && userID != models.SuperAdminID { return false }
|
||||
if userID == image.UserId || userID == models.SuperAdminID { return true }
|
||||
user, exists := middlewares.GetCurrentUser(c)
|
||||
if exists && requiredPerm != "" && user.Permission.HasPermission(requiredPerm) {
|
||||
return true
|
||||
}
|
||||
|
||||
currentUserUUID := GetUUID(c)
|
||||
currentUsername := c.GetString("username")
|
||||
if image.UUID != "" && image.UUID == currentUserUUID && md5.Md5(currentUsername+image.FileName) == image.MD5 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deleteLocalFile 删除本地 uploads 下相对路径文件(忽略不存在)。
|
||||
func deleteLocalFile(fileUrl string) {
|
||||
if fileUrl == "" {
|
||||
return
|
||||
}
|
||||
relPath := strings.TrimPrefix(strings.TrimPrefix(fileUrl, "/"), "uploads/")
|
||||
filePath := filepath.Join("./uploads", relPath)
|
||||
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[本地存储] 删除文件失败 path=%s err=%v", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteDefaultStorageImage 删除本地默认存储上的原图与缩略图。
|
||||
func DeleteDefaultStorageImage(image models.Image) bool {
|
||||
deleteLocalFile(image.Url)
|
||||
deleteLocalFile(image.Thumbnail)
|
||||
return true
|
||||
}
|
||||
|
||||
// deleteS3Object 删除 S3/R2 单个对象。
|
||||
func deleteS3Object(ctx context.Context, client *minio.Client, bucketName, fileUrl string) error {
|
||||
if fileUrl == "" {
|
||||
return nil
|
||||
}
|
||||
objectKey := strings.TrimPrefix(fileUrl, "/")
|
||||
return client.RemoveObject(ctx, bucketName, objectKey, minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
// DeleteR2StorageImage 删除 R2 上的原图与缩略图。
|
||||
func DeleteR2StorageImage(image models.Image, bucket models.Buckets) bool {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s3Client, err := s3.NewS3Client(setting, bucket)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
storageConfig := buckets.ConvertToR2Bucket(bucket.Config)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := deleteS3Object(ctx, s3Client, storageConfig.R2Bucket, image.Url); err != nil {
|
||||
log.Printf("[R2] 删除原图失败 bucket=%s key=%s err=%v", storageConfig.R2Bucket, image.Url, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if image.Thumbnail != "" {
|
||||
if err := deleteS3Object(ctx, s3Client, storageConfig.R2Bucket, image.Thumbnail); err != nil {
|
||||
log.Printf("[R2] 删除缩略图失败 bucket=%s key=%s err=%v", storageConfig.R2Bucket, image.Thumbnail, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteS3StorageImage 删除 S3 上的原图与缩略图。
|
||||
func DeleteS3StorageImage(image models.Image, bucket models.Buckets) bool {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("[S3] 获取系统配置失败 bucketId=%d err=%v", bucket.Id, err)
|
||||
return false
|
||||
}
|
||||
s3Client, err := s3.NewS3Client(setting, bucket)
|
||||
if err != nil {
|
||||
log.Printf("[S3] 创建客户端失败 bucketId=%d err=%v", bucket.Id, err)
|
||||
return false
|
||||
}
|
||||
|
||||
storageConfig := buckets.ConvertToS3Bucket(bucket.Config)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := deleteS3Object(ctx, s3Client, storageConfig.S3Bucket, image.Url); err != nil {
|
||||
log.Printf("[S3] 删除原图失败 bucket=%s key=%s err=%v", storageConfig.S3Bucket, image.Url, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if image.Thumbnail != "" {
|
||||
if err := deleteS3Object(ctx, s3Client, storageConfig.S3Bucket, image.Thumbnail); err != nil {
|
||||
log.Printf("[S3] 删除缩略图失败 bucket=%s key=%s err=%v", storageConfig.S3Bucket, image.Thumbnail, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteWebDavStorageImage 删除 WebDAV 上的原图与缩略图。
|
||||
func DeleteWebDavStorageImage(image models.Image, bucket models.Buckets) bool {
|
||||
storageConfig := buckets.ConvertToWebDavBucket(bucket.Config)
|
||||
client := webdav.Client(webdav.Config{
|
||||
BaseURL: storageConfig.WebdavURL,
|
||||
Username: storageConfig.WebdavUser,
|
||||
Password: storageConfig.WebdavPass,
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
deleteFile := func(filePath string) bool {
|
||||
if filePath == "" {
|
||||
return true
|
||||
}
|
||||
if err := client.WebDAVDelete(context.TODO(), filePath); err != nil {
|
||||
log.Printf("[WebDAV] 删除文件失败 path=%s err=%v", filePath, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 两者都必须成功才算成功
|
||||
mainOk := deleteFile(image.Url)
|
||||
thumbOk := deleteFile(image.Thumbnail)
|
||||
return mainOk && thumbOk
|
||||
}
|
||||
|
||||
// DeleteFtpStorageImage 删除 FTP 上的原图与缩略图。
|
||||
func DeleteFtpStorageImage(image models.Image, bucket models.Buckets) bool {
|
||||
storageConfig := buckets.ConvertToFTPBucket(bucket.Config)
|
||||
ftpUtil := ftp.NewFTPUtil(ftp.FTPConfig{
|
||||
Host: storageConfig.FTPHost,
|
||||
Port: storageConfig.FTPPort,
|
||||
User: storageConfig.FTPUser,
|
||||
Password: storageConfig.FTPPass,
|
||||
Timeout: 60,
|
||||
})
|
||||
|
||||
if err := ftpUtil.DeleteImage(image.Url); err != nil {
|
||||
log.Printf("[FTP] 删除原图失败 bucketId=%d path=%s err=%v", bucket.Id, image.Url, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if image.Thumbnail != "" {
|
||||
if err := ftpUtil.DeleteImage(image.Thumbnail); err != nil {
|
||||
log.Printf("[FTP] 删除缩略图失败 bucketId=%d path=%s err=%v", bucket.Id, image.Thumbnail, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteTelegramStorageImage 删除 Telegram 消息中的图片与缩略图。
|
||||
func DeleteTelegramStorageImage(image models.Image, bucket models.Buckets) bool {
|
||||
storageConfig := buckets.ConvertToTelegramBucket(bucket.Config)
|
||||
|
||||
dbInstance := database.GetDB()
|
||||
if dbInstance == nil || dbInstance.DB == nil {
|
||||
log.Printf("[TG] 数据库实例为空,无法查询TG文件记录")
|
||||
return false
|
||||
}
|
||||
|
||||
var telegramModel models.ImageTeleGram
|
||||
if err := dbInstance.DB.Where("file_name = ?", image.FileName).First(&telegramModel).Error; err != nil {
|
||||
log.Printf("[TG] 查询TG文件记录失败 fileName=%s err=%v", image.FileName, err)
|
||||
return false
|
||||
}
|
||||
|
||||
tgClient := telegram.NewClient(storageConfig.TGBotToken)
|
||||
tgClient.Timeout = 20 * time.Second
|
||||
tgClient.Retry = 3
|
||||
|
||||
uploader := telegram.NewTelegramUploader(tgClient)
|
||||
uploader.DeletePhoto(storageConfig.TGReceivers, telegramModel.TGMessageId)
|
||||
|
||||
if image.Thumbnail != "" && telegramModel.TGThumbnailMessageId != 0 {
|
||||
uploader.DeletePhoto(storageConfig.TGReceivers, telegramModel.TGThumbnailMessageId)
|
||||
}
|
||||
|
||||
return true
|
||||
return role == models.RoleAdmin && exists && requiredPerm != "" && user.Permission.HasPermission(requiredPerm)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DirectUploadGuard keeps task responses (especially short-lived upload URLs)
|
||||
// out of caches and makes this first version explicitly non-guest.
|
||||
func DirectUploadGuard() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
if c.GetInt("user_id") <= 0 || c.GetInt("user_role") == models.RoleGuest {
|
||||
directUploadHTTPError(c, http.StatusForbidden, "direct_upload_unavailable", "游客请使用普通上传", false)
|
||||
return
|
||||
}
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
origin := strings.TrimRight(strings.TrimSpace(c.GetHeader("Origin")), "/")
|
||||
if origin != "" && (config.App == nil || origin != strings.TrimRight(config.App.AppURL, "/")) {
|
||||
directUploadHTTPError(c, http.StatusForbidden, "origin_rejected", "请求来源不允许", false)
|
||||
return
|
||||
}
|
||||
}
|
||||
if id := c.Param("id"); id != "" {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
directUploadHTTPError(c, http.StatusBadRequest, "invalid_task_id", "上传任务编号无效", false)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func directUploadHTTPError(c *gin.Context, status int, code, message string, retryable bool) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message, "error_code": code, "retryable": retryable})
|
||||
}
|
||||
|
||||
func respondDirectUpload(c *gin.Context, data any, err error) {
|
||||
if err != nil {
|
||||
var taskError *services.DirectUploadError
|
||||
if errors.As(err, &taskError) {
|
||||
directUploadHTTPError(c, taskError.Status, taskError.Code, taskError.Message, taskError.Retryable)
|
||||
return
|
||||
}
|
||||
// SDK errors can contain presigned URLs or credentials. Never serialize them.
|
||||
directUploadHTTPError(c, http.StatusInternalServerError, "upload_service_error", "上传服务暂时不可用,请稍后重试", true)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": "ok", "data": data})
|
||||
}
|
||||
|
||||
func CreateDirectUpload(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
|
||||
var req services.CreateDirectUploadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
directUploadHTTPError(c, http.StatusBadRequest, "invalid_upload_request", "上传参数无效", false)
|
||||
return
|
||||
}
|
||||
data, err := services.CreateDirectUpload(c.Request.Context(), c.GetInt("user_id"), c.GetInt("user_role"), req)
|
||||
respondDirectUpload(c, data, err)
|
||||
}
|
||||
|
||||
func ListDirectUploads(c *gin.Context) {
|
||||
data, err := services.ListDirectUploads(c.Request.Context(), c.GetInt("user_id"))
|
||||
respondDirectUpload(c, gin.H{"tasks": data}, err)
|
||||
}
|
||||
func GetDirectUpload(c *gin.Context) {
|
||||
data, err := services.GetDirectUpload(c.Request.Context(), c.GetInt("user_id"), c.Param("id"))
|
||||
respondDirectUpload(c, data, err)
|
||||
}
|
||||
func SignDirectUpload(c *gin.Context) {
|
||||
data, err := services.SignDirectUpload(c.Request.Context(), c.GetInt("user_id"), c.Param("id"))
|
||||
respondDirectUpload(c, data, err)
|
||||
}
|
||||
func CompleteDirectUpload(c *gin.Context) {
|
||||
data, err := services.CompleteDirectUpload(c.Request.Context(), c.GetInt("user_id"), c.Param("id"))
|
||||
respondDirectUpload(c, data, err)
|
||||
}
|
||||
func RetryDirectUpload(c *gin.Context) {
|
||||
data, err := services.RetryDirectUpload(c.Request.Context(), c.GetInt("user_id"), c.Param("id"))
|
||||
respondDirectUpload(c, data, err)
|
||||
}
|
||||
func CancelDirectUpload(c *gin.Context) {
|
||||
data, err := services.CancelDirectUpload(c.Request.Context(), c.GetInt("user_id"), c.Param("id"))
|
||||
respondDirectUpload(c, data, err)
|
||||
}
|
||||
func FallbackDirectUpload(c *gin.Context) {
|
||||
// Reject foreign/missing tasks before accepting or buffering their file body.
|
||||
if _, err := services.GetDirectUpload(c.Request.Context(), c.GetInt("user_id"), c.Param("id")); err != nil {
|
||||
respondDirectUpload(c, nil, err)
|
||||
return
|
||||
}
|
||||
// Bound multipart parsing before FormFile can spool arbitrary data to disk.
|
||||
// The service additionally enforces the task's exact original size/hash.
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, services.DirectUploadMaxBytes+(1<<20))
|
||||
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
directUploadHTTPError(c, http.StatusRequestEntityTooLarge, "file_too_large", "中转上传文件超过允许大小", false)
|
||||
} else {
|
||||
directUploadHTTPError(c, http.StatusBadRequest, "invalid_upload_body", "中转文件传输不完整,请重新上传", true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if c.Request.MultipartForm != nil {
|
||||
defer c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
if c.Request.MultipartForm == nil { directUploadHTTPError(c,http.StatusBadRequest,"invalid_upload_body","必须提供 multipart 文件",false);return }
|
||||
files := c.Request.MultipartForm.File["file"]
|
||||
if len(files) != 1 {
|
||||
directUploadHTTPError(c, http.StatusBadRequest, "invalid_upload_body", "每个上传任务必须包含一个文件", false)
|
||||
return
|
||||
}
|
||||
file, err := files[0].Open()
|
||||
if err != nil {
|
||||
directUploadHTTPError(c, http.StatusBadRequest, "invalid_upload_body", "无法读取中转文件,请重试", true)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := services.FallbackDirectUpload(c.Request.Context(), c.GetInt("user_id"), c.Param("id"), file)
|
||||
respondDirectUpload(c, data, err)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package controllers
|
||||
|
||||
import "oneimg/backend/utils/images"
|
||||
|
||||
type downloadedImageInfo struct { MIME string }
|
||||
func inspectDownloadedImage(data []byte)(downloadedImageInfo,error){
|
||||
_,actual,err:=images.ValidateDirectImage(data,"")
|
||||
return downloadedImageInfo{MIME:actual},err
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,295 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"oneimg/backend/models"
|
||||
settingsutil "oneimg/backend/utils/settings"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartCASLogin 以含 state 的精确 service URL 启动 CAS 登录。
|
||||
func StartCASLogin(c *gin.Context) {
|
||||
setting, err := settingsutil.GetSettings()
|
||||
if err != nil || !casSettingsReady(setting) {
|
||||
externalAuthFailure(c, "cas", "not_configured", err)
|
||||
return
|
||||
}
|
||||
serverURL, _ := normalizeCASServerURL(setting.CASServerURL)
|
||||
serviceBase, err := casCallbackURL(setting)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "cas", "not_configured", err)
|
||||
return
|
||||
}
|
||||
state, err := randomURLToken(32)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "cas", "internal_error", err)
|
||||
return
|
||||
}
|
||||
serviceURL, err := addQueryValue(serviceBase, "state", state)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "cas", "internal_error", err)
|
||||
return
|
||||
}
|
||||
flow := models.ExternalAuthFlow{
|
||||
StateHash: hashExternalAuthState(state),
|
||||
Provider: "cas",
|
||||
Issuer: serverURL,
|
||||
ServiceURL: serviceURL,
|
||||
ExpiresAt: time.Now().Add(externalAuthFlowTTL),
|
||||
}
|
||||
if err := saveExternalAuthFlow(c, state, &flow); err != nil {
|
||||
externalAuthFailure(c, "cas", "internal_error", err)
|
||||
return
|
||||
}
|
||||
loginURL, err := casProtocolURL(serverURL, "/login", url.Values{"service": []string{serviceURL}})
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "cas", "internal_error", err)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, loginURL)
|
||||
}
|
||||
|
||||
// CASCallback 固定使用 CAS 3.0 /p3/serviceValidate 并解析带命名空间的 XML。
|
||||
func CASCallback(c *gin.Context) {
|
||||
state, stateOK := singleQueryValue(c, "state", 512)
|
||||
ticket, ticketOK := singleQueryValue(c, "ticket", 4096)
|
||||
c.Request.URL.RawQuery = ""
|
||||
if !stateOK {
|
||||
externalAuthFailure(c, "cas", "invalid_state", nil)
|
||||
return
|
||||
}
|
||||
flow, err := consumeExternalAuthFlow(c, state, "cas")
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "cas", "invalid_state", err)
|
||||
return
|
||||
}
|
||||
if !ticketOK || strings.TrimSpace(ticket) == "" {
|
||||
externalAuthFailure(c, "cas", "missing_ticket", nil)
|
||||
return
|
||||
}
|
||||
|
||||
setting, err := settingsutil.GetSettings()
|
||||
if err != nil || !setting.CASEnable {
|
||||
externalAuthFailure(c, "cas", "not_configured", err)
|
||||
return
|
||||
}
|
||||
currentServer, serverErr := normalizeCASServerURL(setting.CASServerURL)
|
||||
if serverErr != nil || currentServer != flow.Issuer {
|
||||
externalAuthFailure(c, "cas", "not_configured", errors.New("CAS 配置在登录过程中已变更"))
|
||||
return
|
||||
}
|
||||
casResult, err := validateCAS3Ticket(c.Request.Context(), flow.Issuer, flow.ServiceURL, ticket)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "cas", "invalid_ticket", err)
|
||||
return
|
||||
}
|
||||
|
||||
username := casResult.User
|
||||
email := firstCASAttribute(casResult.Attributes, "email", "mail")
|
||||
displayName := firstCASAttribute(casResult.Attributes, "displayName", "name", "cn")
|
||||
preferredUsername := firstCASAttribute(casResult.Attributes, "preferred_username", "username", "uid")
|
||||
if preferredUsername != "" {
|
||||
username = preferredUsername
|
||||
}
|
||||
user, err := resolveExternalCallbackUser(externalIdentityProfile{
|
||||
Provider: "cas",
|
||||
Issuer: flow.Issuer,
|
||||
Subject: casResult.User,
|
||||
Username: username,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
}, casResult.User, setting.CASSuperAdminUsername, setting.CASAutoProvision)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "cas", "account_error", err)
|
||||
return
|
||||
}
|
||||
if _, err := saveUserSession(c, user); err != nil {
|
||||
externalAuthFailure(c, "cas", "internal_error", err)
|
||||
return
|
||||
}
|
||||
externalAuthSuccess(c, "cas")
|
||||
}
|
||||
|
||||
func casProtocolURL(serverURL, endpoint string, query url.Values) (string, error) {
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/") + endpoint
|
||||
parsed.RawPath = ""
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
type casAuthenticationSuccess struct {
|
||||
XMLName xml.Name
|
||||
User casXMLValue `xml:"user"`
|
||||
Attributes casAttributes `xml:"attributes"`
|
||||
}
|
||||
|
||||
type casXMLValue struct {
|
||||
XMLName xml.Name
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type casAuthenticationFailure struct {
|
||||
XMLName xml.Name
|
||||
Code string `xml:"code,attr"`
|
||||
}
|
||||
|
||||
type casServiceResponse struct {
|
||||
XMLName xml.Name
|
||||
Success *casAuthenticationSuccess `xml:"authenticationSuccess"`
|
||||
Failure *casAuthenticationFailure `xml:"authenticationFailure"`
|
||||
}
|
||||
|
||||
type casAttributes map[string][]string
|
||||
|
||||
func (attributes *casAttributes) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error {
|
||||
result := make(casAttributes)
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
var content string
|
||||
if err := decoder.DecodeElement(&content, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
if value.Name.Space != casXMLNamespace {
|
||||
continue
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content != "" {
|
||||
result[value.Name.Local] = append(result[value.Name.Local], content)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if value.Name == start.Name {
|
||||
*attributes = result
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type casValidatedIdentity struct {
|
||||
User string
|
||||
Attributes casAttributes
|
||||
}
|
||||
|
||||
func validateCAS3Ticket(ctx context.Context, serverURL, serviceURL, ticket string) (casValidatedIdentity, error) {
|
||||
var validated casValidatedIdentity
|
||||
validateURL, err := casProtocolURL(serverURL, "/p3/serviceValidate", url.Values{
|
||||
"service": []string{serviceURL},
|
||||
"ticket": []string{ticket},
|
||||
})
|
||||
if err != nil {
|
||||
return validated, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, validateURL, nil)
|
||||
if err != nil {
|
||||
return validated, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/xml, text/xml")
|
||||
response, err := externalHTTPClient(true, casMaxResponseBody).Do(request)
|
||||
if err != nil {
|
||||
return validated, fmt.Errorf("CAS3 票据校验请求失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return validated, fmt.Errorf("CAS3 票据校验返回 HTTP %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, casMaxResponseBody+1))
|
||||
if err != nil {
|
||||
return validated, fmt.Errorf("读取 CAS3 XML 失败: %w", err)
|
||||
}
|
||||
if int64(len(body)) > casMaxResponseBody {
|
||||
return validated, errors.New("CAS3 XML 响应超过 1 MiB")
|
||||
}
|
||||
return parseCAS3ServiceResponse(body)
|
||||
}
|
||||
|
||||
func parseCAS3ServiceResponse(body []byte) (casValidatedIdentity, error) {
|
||||
var validated casValidatedIdentity
|
||||
upperBody := bytes.ToUpper(body)
|
||||
if bytes.Contains(upperBody, []byte("<!DOCTYPE")) || bytes.Contains(upperBody, []byte("<!ENTITY")) {
|
||||
return validated, errors.New("CAS3 XML 不允许 DTD 或自定义实体")
|
||||
}
|
||||
decoder := xml.NewDecoder(bytes.NewReader(body))
|
||||
decoder.Strict = true
|
||||
var response casServiceResponse
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return validated, fmt.Errorf("CAS3 XML 解析失败: %w", err)
|
||||
}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return validated, fmt.Errorf("CAS3 XML 尾部数据无效: %w", err)
|
||||
}
|
||||
switch value := token.(type) {
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(value)) != "" {
|
||||
return validated, errors.New("CAS3 XML 包含额外尾部数据")
|
||||
}
|
||||
case xml.Comment:
|
||||
// XML 允许根元素后存在注释。
|
||||
default:
|
||||
return validated, errors.New("CAS3 XML 包含额外根元素")
|
||||
}
|
||||
}
|
||||
if response.XMLName.Local != "serviceResponse" || response.XMLName.Space != casXMLNamespace {
|
||||
return validated, errors.New("CAS3 XML serviceResponse 命名空间无效")
|
||||
}
|
||||
if response.Success != nil && response.Failure != nil {
|
||||
return validated, errors.New("CAS3 XML 同时包含成功与失败响应")
|
||||
}
|
||||
if response.Failure != nil {
|
||||
if response.Failure.XMLName.Space != casXMLNamespace {
|
||||
return validated, errors.New("CAS3 XML authenticationFailure 命名空间无效")
|
||||
}
|
||||
return validated, errors.New("CAS3 票据校验失败")
|
||||
}
|
||||
if response.Success == nil || response.Success.XMLName.Space != casXMLNamespace {
|
||||
return validated, errors.New("CAS3 XML 缺少有效 authenticationSuccess")
|
||||
}
|
||||
if response.Success.User.XMLName.Space != casXMLNamespace {
|
||||
return validated, errors.New("CAS3 XML user 命名空间无效")
|
||||
}
|
||||
user := strings.TrimSpace(response.Success.User.Value)
|
||||
if user == "" || len(user) > 2048 {
|
||||
return validated, errors.New("CAS3 XML 缺少有效 user")
|
||||
}
|
||||
validated.User = user
|
||||
validated.Attributes = response.Success.Attributes
|
||||
return validated, nil
|
||||
}
|
||||
|
||||
func firstCASAttribute(attributes casAttributes, keys ...string) string {
|
||||
for _, wanted := range keys {
|
||||
for key, values := range attributes {
|
||||
if !strings.EqualFold(key, wanted) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/models"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizeOIDCIssuer(raw string) (string, error) {
|
||||
normalized, parsed, err := normalizeExternalURL(raw, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("OIDC Issuer URL 无效: %w", err)
|
||||
}
|
||||
if normalized == "" {
|
||||
return "", nil
|
||||
}
|
||||
if parsed.Scheme != "https" && !isLoopbackHostname(parsed.Hostname()) {
|
||||
return "", errors.New("OIDC Issuer 必须使用 HTTPS(localhost 开发环境除外)")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func normalizeCASServerURL(raw string) (string, error) {
|
||||
normalized, parsed, err := normalizeExternalURL(raw, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS Server URL 无效: %w", err)
|
||||
}
|
||||
if normalized == "" {
|
||||
return "", nil
|
||||
}
|
||||
if parsed.Scheme != "https" && !isLoopbackHostname(parsed.Hostname()) {
|
||||
return "", errors.New("CAS Server 必须使用 HTTPS(localhost 开发环境除外)")
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func normalizeExternalCallbackURL(raw string) (string, error) {
|
||||
normalized, _, err := normalizeExternalURL(raw, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("回调 URL 无效: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeOIDCCallbackURL(raw string) (string, error) {
|
||||
return normalizeConfiguredCallbackURL(raw, "/api/auth/oidc/callback", "state", "code", "error", "error_description")
|
||||
}
|
||||
|
||||
func normalizeCASCallbackURL(raw string) (string, error) {
|
||||
return normalizeConfiguredCallbackURL(raw, "/api/auth/cas/callback", "state", "ticket")
|
||||
}
|
||||
|
||||
func normalizeConfiguredCallbackURL(raw, callbackPath string, reservedQueryKeys ...string) (string, error) {
|
||||
normalized, err := normalizeExternalCallbackURL(raw)
|
||||
if err != nil || normalized == "" {
|
||||
return normalized, err
|
||||
}
|
||||
expected, err := callbackURLFromApp(callbackPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configuredURL, _ := url.Parse(normalized)
|
||||
expectedURL, _ := url.Parse(expected)
|
||||
if !strings.EqualFold(configuredURL.Scheme, expectedURL.Scheme) ||
|
||||
!strings.EqualFold(configuredURL.Host, expectedURL.Host) || configuredURL.Path != expectedURL.Path {
|
||||
return "", fmt.Errorf("回调 URL 必须与 APP_URL 同源且路径为 %s", expectedURL.Path)
|
||||
}
|
||||
query := configuredURL.Query()
|
||||
for _, key := range reservedQueryKeys {
|
||||
if _, exists := query[key]; exists {
|
||||
return "", fmt.Errorf("回调 URL 不得预置协议参数 %s", key)
|
||||
}
|
||||
}
|
||||
return configuredURL.String(), nil
|
||||
}
|
||||
|
||||
func normalizeExternalURL(raw string, allowQuery bool) (string, *url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
if len(raw) > 4096 {
|
||||
return "", nil, errors.New("URL 长度超过限制")
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || !parsed.IsAbs() || parsed.Host == "" {
|
||||
return "", nil, errors.New("必须是完整的绝对 URL")
|
||||
}
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", nil, errors.New("仅支持 HTTP/HTTPS")
|
||||
}
|
||||
if parsed.User != nil || parsed.Fragment != "" || (!allowQuery && (parsed.RawQuery != "" || parsed.ForceQuery)) {
|
||||
return "", nil, errors.New("URL 不得包含用户信息、片段或非法查询参数")
|
||||
}
|
||||
return parsed.String(), parsed, nil
|
||||
}
|
||||
|
||||
func normalizeOIDCScopes(raw string) (string, error) {
|
||||
fields := strings.Fields(raw)
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"openid", "profile", "email"}
|
||||
}
|
||||
seen := make(map[string]bool, len(fields))
|
||||
normalized := make([]string, 0, len(fields))
|
||||
for _, scope := range fields {
|
||||
if len(scope) > 128 || strings.ContainsAny(scope, `"\\`) {
|
||||
return "", errors.New("OIDC Scope 格式不正确")
|
||||
}
|
||||
if !seen[scope] {
|
||||
seen[scope] = true
|
||||
normalized = append(normalized, scope)
|
||||
}
|
||||
}
|
||||
if !seen["openid"] {
|
||||
return "", errors.New("OIDC Scopes 必须包含 openid")
|
||||
}
|
||||
return strings.Join(normalized, " "), nil
|
||||
}
|
||||
|
||||
func oidcCallbackURL(setting models.Settings) (string, error) {
|
||||
if strings.TrimSpace(setting.OIDCRedirectURL) != "" {
|
||||
return normalizeOIDCCallbackURL(setting.OIDCRedirectURL)
|
||||
}
|
||||
return callbackURLFromApp("/api/auth/oidc/callback")
|
||||
}
|
||||
|
||||
func casCallbackURL(setting models.Settings) (string, error) {
|
||||
if strings.TrimSpace(setting.CASServiceURL) != "" {
|
||||
return normalizeCASCallbackURL(setting.CASServiceURL)
|
||||
}
|
||||
return callbackURLFromApp("/api/auth/cas/callback")
|
||||
}
|
||||
|
||||
func callbackURLFromApp(callbackPath string) (string, error) {
|
||||
if config.App == nil {
|
||||
return "", errors.New("APP_URL 未配置")
|
||||
}
|
||||
_, parsed, err := normalizeExternalURL(config.App.AppURL, false)
|
||||
if err != nil || parsed == nil {
|
||||
return "", errors.New("APP_URL 无效")
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/") + callbackPath
|
||||
parsed.RawPath = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func oidcSettingsComplete(setting models.Settings) bool {
|
||||
issuer, issuerErr := normalizeOIDCIssuer(setting.OIDCIssuer)
|
||||
callback, callbackErr := oidcCallbackURL(setting)
|
||||
scopes, scopeErr := normalizeOIDCScopes(setting.OIDCScopes)
|
||||
claim := strings.TrimSpace(setting.OIDCUsernameClaim)
|
||||
clientID := strings.TrimSpace(setting.OIDCClientID)
|
||||
clientSecret := strings.TrimSpace(setting.OIDCClientSecret)
|
||||
return issuerErr == nil && issuer != "" && callbackErr == nil && callback != "" && scopeErr == nil && scopes != "" &&
|
||||
clientID != "" && len(clientID) <= 512 && clientSecret != "" && len(clientSecret) <= 4096 && oidcClaimNameRegex.MatchString(claim)
|
||||
}
|
||||
|
||||
func oidcSettingsReady(setting models.Settings) bool {
|
||||
return setting.OIDCEnable && oidcSettingsComplete(setting)
|
||||
}
|
||||
|
||||
func casSettingsComplete(setting models.Settings) bool {
|
||||
server, serverErr := normalizeCASServerURL(setting.CASServerURL)
|
||||
callback, callbackErr := casCallbackURL(setting)
|
||||
return serverErr == nil && server != "" && callbackErr == nil && callback != ""
|
||||
}
|
||||
|
||||
func casSettingsReady(setting models.Settings) bool {
|
||||
return setting.CASEnable && casSettingsComplete(setting)
|
||||
}
|
||||
|
||||
func externalLoginDisplayName(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return truncateUTF8(value, 40)
|
||||
}
|
||||
|
||||
func isLoopbackHostname(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
func addQueryValue(rawURL, key, value string) (string, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set(key, value)
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func truncateASCII(value string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(value) <= max {
|
||||
return value
|
||||
}
|
||||
return value[:max]
|
||||
}
|
||||
|
||||
func truncateUTF8(value string, max int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= max {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"oneimg/backend/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func hashExternalAuthState(state string) string {
|
||||
sum := sha256.Sum256([]byte(state))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func externalAuthCookieName(state string) string {
|
||||
hash := hashExternalAuthState(state)
|
||||
return externalAuthCookiePrefix + hash[:16]
|
||||
}
|
||||
|
||||
func externalAuthCookieSignature(state string) string {
|
||||
secret := ""
|
||||
if config.App != nil {
|
||||
secret = config.App.SessionSecret
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(state))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func setExternalAuthCookie(c *gin.Context, state string, ttl time.Duration) {
|
||||
secure := config.App != nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(config.App.AppURL)), "https://")
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: externalAuthCookieName(state),
|
||||
Value: externalAuthCookieSignature(state),
|
||||
Path: "/api/auth",
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
Expires: time.Now().Add(ttl),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearExternalAuthCookie(c *gin.Context, state string) {
|
||||
secure := config.App != nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(config.App.AppURL)), "https://")
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: externalAuthCookieName(state),
|
||||
Value: "",
|
||||
Path: "/api/auth",
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(1, 0),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func saveExternalAuthFlow(c *gin.Context, state string, flow *models.ExternalAuthFlow) error {
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
}
|
||||
if flow.StateHash != hashExternalAuthState(state) {
|
||||
return errors.New("登录事务 state 不匹配")
|
||||
}
|
||||
_ = db.DB.Where("expires_at < ?", time.Now()).Delete(&models.ExternalAuthFlow{}).Error
|
||||
if err := db.DB.Create(flow).Error; err != nil {
|
||||
return fmt.Errorf("保存登录事务失败: %w", err)
|
||||
}
|
||||
setExternalAuthCookie(c, state, externalAuthFlowTTL)
|
||||
return nil
|
||||
}
|
||||
|
||||
func consumeExternalAuthFlow(c *gin.Context, state, provider string) (models.ExternalAuthFlow, error) {
|
||||
var flow models.ExternalAuthFlow
|
||||
if len(state) < 32 || len(state) > 512 {
|
||||
return flow, errors.New("state 长度无效")
|
||||
}
|
||||
cookieName := externalAuthCookieName(state)
|
||||
cookieValue, err := c.Cookie(cookieName)
|
||||
if err != nil || subtle.ConstantTimeCompare([]byte(cookieValue), []byte(externalAuthCookieSignature(state))) != 1 {
|
||||
return flow, errors.New("登录事务 Cookie 校验失败")
|
||||
}
|
||||
clearExternalAuthCookie(c, state)
|
||||
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
return flow, errors.New("数据库未初始化")
|
||||
}
|
||||
stateHash := hashExternalAuthState(state)
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("state_hash = ?", stateHash).First(&flow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Where("state_hash = ?", stateHash).Delete(&models.ExternalAuthFlow{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("登录事务已被消费")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return flow, err
|
||||
}
|
||||
if flow.Provider != provider || time.Now().After(flow.ExpiresAt) {
|
||||
return models.ExternalAuthFlow{}, errors.New("登录事务已过期或类型不匹配")
|
||||
}
|
||||
return flow, nil
|
||||
}
|
||||
|
||||
func randomURLToken(size int) (string, error) {
|
||||
value := make([]byte, size)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func singleQueryValue(c *gin.Context, key string, maxLength int) (string, bool) {
|
||||
values, ok := c.Request.URL.Query()[key]
|
||||
if !ok || len(values) != 1 || len(values[0]) > maxLength {
|
||||
return "", false
|
||||
}
|
||||
return values[0], true
|
||||
}
|
||||
|
||||
func externalAuthSuccess(c *gin.Context, provider string) {
|
||||
var db *gorm.DB
|
||||
if current := database.GetDB(); current != nil {
|
||||
db = current.DB
|
||||
}
|
||||
externalAuthSuccessDB(c, db, provider)
|
||||
}
|
||||
|
||||
func externalAuthSuccessDB(c *gin.Context, db *gorm.DB, provider string) {
|
||||
if userID, ok := sessions.Default(c).Get("user_id").(int); ok {
|
||||
recordAccountLogin(c, db, userID, provider)
|
||||
}
|
||||
query := url.Values{"external_login": []string{"success"}, "provider": []string{provider}}
|
||||
c.Redirect(http.StatusFound, "/login?"+query.Encode())
|
||||
}
|
||||
|
||||
func externalAuthFailure(c *gin.Context, provider, code string, internalErr error) {
|
||||
if internalErr != nil {
|
||||
log.Printf("[%s 登录] %s: %s", strings.ToUpper(provider), code, truncateUTF8(internalErr.Error(), 500))
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
"access_denied": true, "invalid_state": true, "missing_code": true,
|
||||
"missing_ticket": true, "invalid_ticket": true, "token_exchange_failed": true,
|
||||
"user_info_failed": true, "account_error": true, "not_configured": true,
|
||||
"provider_error": true, "authentication_failed": true, "callback_failed": true,
|
||||
"internal_error": true,
|
||||
}
|
||||
if !allowed[code] {
|
||||
code = "internal_error"
|
||||
}
|
||||
query := url.Values{"external_login": []string{"error"}, "error_code": []string{code}}
|
||||
c.Redirect(http.StatusFound, "/login?"+query.Encode())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type limitedResponseTransport struct {
|
||||
base http.RoundTripper
|
||||
limit int64
|
||||
}
|
||||
|
||||
func (transport limitedResponseTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
response, err := transport.base.RoundTrip(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.Body = &limitedReadCloser{Reader: io.LimitReader(response.Body, transport.limit+1), Closer: response.Body}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type limitedReadCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
|
||||
func externalHTTPClient(noRedirect bool, responseLimit int64) *http.Client {
|
||||
base, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
base = &http.Transport{}
|
||||
} else {
|
||||
base = base.Clone()
|
||||
}
|
||||
base.ResponseHeaderTimeout = 10 * time.Second
|
||||
base.TLSHandshakeTimeout = 10 * time.Second
|
||||
base.MaxResponseHeaderBytes = 1 << 20
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: limitedResponseTransport{base: base, limit: responseLimit},
|
||||
}
|
||||
if noRedirect {
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func externalOIDCContext(ctx context.Context) context.Context {
|
||||
// Discovery、Token 和 JWKS 端点都应提供最终 URL;禁止 30x 避免 code/secret 被转发。
|
||||
return oidc.ClientContext(ctx, externalHTTPClient(true, externalAuthMaxHTTPBody))
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resolveOrProvisionExternalIdentity(profile externalIdentityProfile) (*models.User, error) {
|
||||
return resolveExternalIdentity(profile, true)
|
||||
}
|
||||
|
||||
func resolveExternalCallbackUser(profile externalIdentityProfile, callbackUsername, superAdminUsername string, allowProvision bool) (*models.User, error) {
|
||||
callbackUsername = strings.TrimSpace(callbackUsername)
|
||||
superAdminUsername = strings.TrimSpace(superAdminUsername)
|
||||
if superAdminUsername != "" && len(callbackUsername) == len(superAdminUsername) &&
|
||||
subtle.ConstantTimeCompare([]byte(callbackUsername), []byte(superAdminUsername)) == 1 {
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
return nil, errors.New("数据库未初始化")
|
||||
}
|
||||
var superAdmin models.User
|
||||
if err := db.DB.Where("id = ? AND role = ?", models.SuperAdminID, models.RoleAdmin).First(&superAdmin).Error; err != nil {
|
||||
return nil, errors.New("本地超级管理员账户不存在或角色无效")
|
||||
}
|
||||
return &superAdmin, nil
|
||||
}
|
||||
return resolveExternalIdentity(profile, allowProvision)
|
||||
}
|
||||
|
||||
func resolveExternalIdentity(profile externalIdentityProfile, allowProvision bool) (*models.User, error) {
|
||||
profile.Provider = strings.ToLower(strings.TrimSpace(profile.Provider))
|
||||
profile.Issuer = strings.TrimSpace(profile.Issuer)
|
||||
if profile.Provider == "" || profile.Issuer == "" || len(profile.Issuer) > 2048 || profile.Subject == "" || len(profile.Subject) > 2048 {
|
||||
return nil, errors.New("外部身份信息不完整")
|
||||
}
|
||||
identityKey := externalIdentityKey(profile.Provider, profile.Issuer, profile.Subject)
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
return nil, errors.New("数据库未初始化")
|
||||
}
|
||||
|
||||
var resolved models.User
|
||||
err := db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var identity models.ExternalIdentity
|
||||
err := tx.Where("identity_key = ?", identityKey).First(&identity).Error
|
||||
if err == nil {
|
||||
if identity.Provider != profile.Provider || identity.Issuer != profile.Issuer || identity.Subject != profile.Subject {
|
||||
return errors.New("外部身份键冲突")
|
||||
}
|
||||
if identity.Disabled || identity.UserID <= 0 {
|
||||
return errors.New("该外部身份已被禁用")
|
||||
}
|
||||
if err := tx.First(&resolved, identity.UserID).Error; err != nil {
|
||||
return errors.New("外部身份绑定的本地用户不存在")
|
||||
}
|
||||
return tx.Model(&identity).Updates(map[string]any{
|
||||
"email": truncateUTF8(profile.Email, 320),
|
||||
"display_name": truncateUTF8(profile.DisplayName, 255),
|
||||
"last_login_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if !allowProvision {
|
||||
return errors.New("管理员未允许外部身份自动创建用户")
|
||||
}
|
||||
|
||||
username, err := availableExternalUsername(tx, profile, identityKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
randomPassword, err := randomURLToken(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved = models.User{
|
||||
Role: models.RoleUser,
|
||||
Username: username,
|
||||
Password: string(hashedPassword),
|
||||
Permission: models.Permission{Buckets: []int{}},
|
||||
}
|
||||
if err := tx.Create(&resolved).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
identity = models.ExternalIdentity{
|
||||
UserID: resolved.ID,
|
||||
Provider: profile.Provider,
|
||||
Issuer: profile.Issuer,
|
||||
Subject: profile.Subject,
|
||||
IdentityKey: identityKey,
|
||||
Email: truncateUTF8(profile.Email, 320),
|
||||
DisplayName: truncateUTF8(profile.DisplayName, 255),
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
return tx.Create(&identity).Error
|
||||
})
|
||||
if err == nil {
|
||||
return &resolved, nil
|
||||
}
|
||||
|
||||
// 并发首次登录时,唯一索引失败的事务会回滚,再读取已成功的绑定。
|
||||
var identity models.ExternalIdentity
|
||||
if lookupErr := db.DB.Where("identity_key = ?", identityKey).First(&identity).Error; lookupErr == nil {
|
||||
if identity.Disabled || identity.UserID <= 0 {
|
||||
return nil, errors.New("该外部身份已被禁用")
|
||||
}
|
||||
if userErr := db.DB.First(&resolved, identity.UserID).Error; userErr == nil {
|
||||
return &resolved, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("创建外部登录用户失败: %w", err)
|
||||
}
|
||||
|
||||
func availableExternalUsername(tx *gorm.DB, profile externalIdentityProfile, identityKey string) (string, error) {
|
||||
base := sanitizeExternalUsername(profile.Username)
|
||||
if base == "" {
|
||||
base = sanitizeExternalUsername(profile.DisplayName)
|
||||
}
|
||||
if base == "" {
|
||||
base = profile.Provider + "_user"
|
||||
}
|
||||
base = safeExternalUsername(base, profile.Provider)
|
||||
if !externalUsernameExists(tx, base) {
|
||||
return base, nil
|
||||
}
|
||||
suffix := "_" + profile.Provider + "_" + identityKey[:8]
|
||||
base = truncateASCII(base, 50-len(suffix)) + suffix
|
||||
if !externalUsernameExists(tx, base) {
|
||||
return base, nil
|
||||
}
|
||||
for i := 2; i <= 100; i++ {
|
||||
numberedSuffix := fmt.Sprintf("%s_%d", suffix, i)
|
||||
candidate := truncateASCII(sanitizeExternalUsername(profile.Username), 50-len(numberedSuffix)) + numberedSuffix
|
||||
candidate = safeExternalUsername(candidate, profile.Provider)
|
||||
if !externalUsernameExists(tx, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("无法生成唯一的本地用户名")
|
||||
}
|
||||
|
||||
func sanitizeExternalUsername(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
var b strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range value {
|
||||
allowed := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._@-", r)
|
||||
if allowed {
|
||||
b.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
} else if !lastUnderscore {
|
||||
b.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
return truncateASCII(strings.Trim(b.String(), "._-@"), 50)
|
||||
}
|
||||
|
||||
func safeExternalUsername(value, provider string) string {
|
||||
value = truncateASCII(value, 50)
|
||||
lower := strings.ToLower(value)
|
||||
if value == "" || lower == "guest" || strings.HasPrefix(lower, "guest_") {
|
||||
value = provider + "_user"
|
||||
}
|
||||
if _, err := uuid.Parse(value); err == nil {
|
||||
value = provider + "_" + truncateASCII(strings.ReplaceAll(value, "-", ""), 43)
|
||||
}
|
||||
return truncateASCII(value, 50)
|
||||
}
|
||||
|
||||
func externalUsernameExists(tx *gorm.DB, username string) bool {
|
||||
var count int64
|
||||
if err := tx.Model(&models.User{}).Where("LOWER(username) = LOWER(?)", username).Count(&count).Error; err != nil {
|
||||
return true
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func externalIdentityKey(provider, issuer, subject string) string {
|
||||
sum := sha256.Sum256([]byte(provider + "\x00" + issuer + "\x00" + subject))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"oneimg/backend/models"
|
||||
settingsutil "oneimg/backend/utils/settings"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartOIDCLogin 启动 Authorization Code + PKCE 流程。
|
||||
func StartOIDCLogin(c *gin.Context) {
|
||||
setting, err := settingsutil.GetSettingsWithSecrets("oidc_client_secret")
|
||||
if err != nil || !oidcSettingsReady(setting) {
|
||||
externalAuthFailure(c, "oidc", "not_configured", err)
|
||||
return
|
||||
}
|
||||
|
||||
issuer, _ := normalizeOIDCIssuer(setting.OIDCIssuer)
|
||||
scopes, _ := normalizeOIDCScopes(setting.OIDCScopes)
|
||||
redirectURL, err := oidcCallbackURL(setting)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "not_configured", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := externalOIDCContext(c.Request.Context())
|
||||
provider, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "provider_error", fmt.Errorf("OIDC discovery 失败: %w", err))
|
||||
return
|
||||
}
|
||||
if err := validateOIDCProviderEndpoints(provider); err != nil {
|
||||
externalAuthFailure(c, "oidc", "provider_error", err)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := randomURLToken(32)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "internal_error", err)
|
||||
return
|
||||
}
|
||||
nonce, err := randomURLToken(32)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "internal_error", err)
|
||||
return
|
||||
}
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
|
||||
flow := models.ExternalAuthFlow{
|
||||
StateHash: hashExternalAuthState(state),
|
||||
Provider: "oidc",
|
||||
Issuer: issuer,
|
||||
ClientID: strings.TrimSpace(setting.OIDCClientID),
|
||||
Nonce: nonce,
|
||||
CodeVerifier: verifier,
|
||||
CallbackURL: redirectURL,
|
||||
ExpiresAt: time.Now().Add(externalAuthFlowTTL),
|
||||
}
|
||||
if err := saveExternalAuthFlow(c, state, &flow); err != nil {
|
||||
externalAuthFailure(c, "oidc", "internal_error", err)
|
||||
return
|
||||
}
|
||||
|
||||
oauthConfig := oauth2.Config{
|
||||
ClientID: flow.ClientID,
|
||||
ClientSecret: setting.OIDCClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: flow.CallbackURL,
|
||||
Scopes: strings.Fields(scopes),
|
||||
}
|
||||
authURL := oauthConfig.AuthCodeURL(
|
||||
state,
|
||||
oidc.Nonce(nonce),
|
||||
oauth2.S256ChallengeOption(verifier),
|
||||
)
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// OIDCCallback 验证 state、PKCE、ID Token 签名/claims 以及 nonce。
|
||||
func OIDCCallback(c *gin.Context) {
|
||||
state, stateOK := singleQueryValue(c, "state", 512)
|
||||
code, codeOK := singleQueryValue(c, "code", 16*1024)
|
||||
providerError, providerErrorOK := singleQueryValue(c, "error", 1024)
|
||||
// 访问日志已跳过该路由;同时尽早丢弃 code,避免后续中间件误用。
|
||||
c.Request.URL.RawQuery = ""
|
||||
|
||||
if !stateOK {
|
||||
externalAuthFailure(c, "oidc", "invalid_state", nil)
|
||||
return
|
||||
}
|
||||
flow, err := consumeExternalAuthFlow(c, state, "oidc")
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "invalid_state", err)
|
||||
return
|
||||
}
|
||||
if providerErrorOK && providerError != "" {
|
||||
code := "provider_error"
|
||||
if providerError == "access_denied" {
|
||||
code = "access_denied"
|
||||
}
|
||||
externalAuthFailure(c, "oidc", code, errors.New("OIDC provider returned an authorization error"))
|
||||
return
|
||||
}
|
||||
if !codeOK || strings.TrimSpace(code) == "" {
|
||||
externalAuthFailure(c, "oidc", "missing_code", nil)
|
||||
return
|
||||
}
|
||||
|
||||
setting, err := settingsutil.GetSettingsWithSecrets("oidc_client_secret")
|
||||
if err != nil || !setting.OIDCEnable || strings.TrimSpace(setting.OIDCClientSecret) == "" {
|
||||
externalAuthFailure(c, "oidc", "not_configured", err)
|
||||
return
|
||||
}
|
||||
currentIssuer, issuerErr := normalizeOIDCIssuer(setting.OIDCIssuer)
|
||||
if issuerErr != nil || currentIssuer != flow.Issuer || strings.TrimSpace(setting.OIDCClientID) != flow.ClientID {
|
||||
externalAuthFailure(c, "oidc", "not_configured", errors.New("OIDC 配置在登录过程中已变更"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := externalOIDCContext(c.Request.Context())
|
||||
provider, err := oidc.NewProvider(ctx, flow.Issuer)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "provider_error", fmt.Errorf("OIDC discovery 失败: %w", err))
|
||||
return
|
||||
}
|
||||
if err := validateOIDCProviderEndpoints(provider); err != nil {
|
||||
externalAuthFailure(c, "oidc", "provider_error", err)
|
||||
return
|
||||
}
|
||||
scopes, _ := normalizeOIDCScopes(setting.OIDCScopes)
|
||||
oauthConfig := oauth2.Config{
|
||||
ClientID: flow.ClientID,
|
||||
ClientSecret: setting.OIDCClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: flow.CallbackURL,
|
||||
Scopes: strings.Fields(scopes),
|
||||
}
|
||||
token, err := oauthConfig.Exchange(ctx, code, oauth2.VerifierOption(flow.CodeVerifier))
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "token_exchange_failed", fmt.Errorf("OIDC code 交换失败: %w", err))
|
||||
return
|
||||
}
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || strings.TrimSpace(rawIDToken) == "" {
|
||||
externalAuthFailure(c, "oidc", "token_exchange_failed", errors.New("OIDC token 响应缺少 id_token"))
|
||||
return
|
||||
}
|
||||
idToken, err := provider.Verifier(&oidc.Config{ClientID: flow.ClientID}).Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "authentication_failed", fmt.Errorf("OIDC ID Token 校验失败: %w", err))
|
||||
return
|
||||
}
|
||||
claims, err := validateOIDCIDToken(idToken, flow, token.AccessToken)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "authentication_failed", err)
|
||||
return
|
||||
}
|
||||
|
||||
usernameClaim := strings.TrimSpace(setting.OIDCUsernameClaim)
|
||||
username := oidcStringClaim(claims, usernameClaim)
|
||||
if username == "" {
|
||||
username = oidcStringClaim(claims, "preferred_username")
|
||||
}
|
||||
email := oidcStringClaim(claims, "email")
|
||||
displayName := oidcStringClaim(claims, "name")
|
||||
if username == "" {
|
||||
username = email
|
||||
}
|
||||
if username == "" {
|
||||
username = displayName
|
||||
}
|
||||
if username == "" {
|
||||
username = idToken.Subject
|
||||
}
|
||||
|
||||
user, err := resolveExternalCallbackUser(externalIdentityProfile{
|
||||
Provider: "oidc",
|
||||
Issuer: flow.Issuer,
|
||||
Subject: idToken.Subject,
|
||||
Username: username,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
}, username, setting.OIDCSuperAdminUsername, setting.OIDCAutoProvision)
|
||||
if err != nil {
|
||||
externalAuthFailure(c, "oidc", "account_error", err)
|
||||
return
|
||||
}
|
||||
if _, err := saveUserSession(c, user); err != nil {
|
||||
externalAuthFailure(c, "oidc", "internal_error", err)
|
||||
return
|
||||
}
|
||||
externalAuthSuccess(c, "oidc")
|
||||
}
|
||||
|
||||
func validateOIDCIDToken(idToken *oidc.IDToken, flow models.ExternalAuthFlow, accessToken string) (map[string]json.RawMessage, error) {
|
||||
if idToken == nil || strings.TrimSpace(idToken.Subject) == "" || len(idToken.Subject) > 2048 {
|
||||
return nil, errors.New("OIDC ID Token 缺少有效 sub")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(flow.Nonce)) != 1 {
|
||||
return nil, errors.New("OIDC nonce 校验失败")
|
||||
}
|
||||
if idToken.IssuedAt.IsZero() || idToken.IssuedAt.After(time.Now().Add(5*time.Minute)) {
|
||||
return nil, errors.New("OIDC iat 无效")
|
||||
}
|
||||
if idToken.AccessTokenHash != "" {
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return nil, errors.New("OIDC at_hash 存在但缺少 access token")
|
||||
}
|
||||
if err := idToken.VerifyAccessToken(accessToken); err != nil {
|
||||
return nil, fmt.Errorf("OIDC at_hash 校验失败: %w", err)
|
||||
}
|
||||
}
|
||||
var claims map[string]json.RawMessage
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("OIDC claims 解析失败: %w", err)
|
||||
}
|
||||
azp := oidcStringClaim(claims, "azp")
|
||||
if len(idToken.Audience) > 1 && azp == "" {
|
||||
return nil, errors.New("OIDC 多 audience Token 缺少 azp")
|
||||
}
|
||||
if azp != "" && azp != flow.ClientID {
|
||||
return nil, errors.New("OIDC azp 校验失败")
|
||||
}
|
||||
if raw, ok := claims["nbf"]; ok {
|
||||
var nbf json.Number
|
||||
if err := json.Unmarshal(raw, &nbf); err != nil {
|
||||
return nil, errors.New("OIDC nbf 格式无效")
|
||||
}
|
||||
nbfSeconds, err := nbf.Int64()
|
||||
if err != nil || time.Unix(nbfSeconds, 0).After(time.Now().Add(time.Minute)) {
|
||||
return nil, errors.New("OIDC Token 尚未生效")
|
||||
}
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func oidcStringClaim(claims map[string]json.RawMessage, name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
raw, ok := claims[name]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func validateOIDCProviderEndpoints(provider *oidc.Provider) error {
|
||||
if provider == nil {
|
||||
return errors.New("OIDC provider 为空")
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
var metadata struct {
|
||||
JWKSURL string `json:"jwks_uri"`
|
||||
}
|
||||
if err := provider.Claims(&metadata); err != nil {
|
||||
return fmt.Errorf("OIDC discovery metadata 解析失败: %w", err)
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"authorization_endpoint": endpoint.AuthURL,
|
||||
"token_endpoint": endpoint.TokenURL,
|
||||
"jwks_uri": metadata.JWKSURL,
|
||||
} {
|
||||
_, parsed, err := normalizeExternalURL(raw, true)
|
||||
if err != nil || parsed == nil {
|
||||
return fmt.Errorf("OIDC %s 无效", name)
|
||||
}
|
||||
if parsed.Scheme != "https" && !isLoopbackHostname(parsed.Hostname()) {
|
||||
return fmt.Errorf("OIDC %s 必须使用 HTTPS", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/result"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func folderHTTPError(c *gin.Context, err error) {
|
||||
var e *services.GalleryFolderError
|
||||
if errors.As(err, &e) {
|
||||
c.AbortWithStatusJSON(e.Status, gin.H{"code": e.Status, "message": e.Message, "error_code": e.Code})
|
||||
return
|
||||
}
|
||||
internalFailure(c, "文件夹操作失败,请稍后重试", err)
|
||||
}
|
||||
func requireFolderAccount(c *gin.Context) bool {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
if c.GetInt("user_id") <= 0 || c.GetInt("user_role") == models.RoleGuest {
|
||||
folderHTTPError(c, &services.GalleryFolderError{Status: 403, Code: "folders_unavailable", Message: "请登录后使用文件夹"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func parseFolderID(raw string, allowZero bool) (int, error) {
|
||||
// Reject signs, spaces, fractions and overflow rather than guessing intent.
|
||||
valid := raw != ""
|
||||
for _, ch := range raw {
|
||||
if ch < '0' || ch > '9' {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
id, err := strconv.Atoi(raw)
|
||||
if !valid || err != nil || id < 0 || (!allowZero && id == 0) {
|
||||
return 0, &services.GalleryFolderError{Status: 400, Code: "invalid_folder_id", Message: "文件夹ID无效"}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func validateUploadFolder(c *gin.Context, folderID int) bool {
|
||||
err := services.ValidateFolderSelection(database.GetDB().DB.WithContext(c.Request.Context()), c.GetInt("user_id"), c.GetInt("user_role"), folderID)
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func multipartUploadFolder(c *gin.Context) (int, bool) {
|
||||
values := c.Request.MultipartForm.Value["folder_id"]
|
||||
if len(values) == 0 {
|
||||
return 0, true
|
||||
}
|
||||
if len(values) != 1 {
|
||||
folderHTTPError(c, &services.GalleryFolderError{Status: 400, Code: "invalid_folder_id", Message: "只能指定一个文件夹"})
|
||||
return 0, false
|
||||
}
|
||||
id, err := parseFolderID(values[0], true)
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return 0, false
|
||||
}
|
||||
return id, validateUploadFolder(c, id)
|
||||
}
|
||||
func GetFolders(c *gin.Context) {
|
||||
if !requireFolderAccount(c) {
|
||||
return
|
||||
}
|
||||
r, err := services.ListGalleryFolders(database.GetDB().DB.WithContext(c.Request.Context()), c.GetInt("user_id"))
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("ok", r))
|
||||
}
|
||||
func CreateFolder(c *gin.Context) { writeFolder(c, false) }
|
||||
func UpdateFolder(c *gin.Context) { writeFolder(c, true) }
|
||||
func writeFolder(c *gin.Context, rename bool) {
|
||||
if !requireFolderAccount(c) {
|
||||
return
|
||||
}
|
||||
var id int
|
||||
var err error
|
||||
if rename {
|
||||
id, err = parseFolderID(c.Param("id"), false)
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8<<10)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, result.Error(400, "文件夹参数格式错误"))
|
||||
return
|
||||
}
|
||||
db := database.GetDB().DB.WithContext(c.Request.Context())
|
||||
descriptions := []string{}
|
||||
if req.Description != nil {
|
||||
descriptions = append(descriptions, *req.Description)
|
||||
}
|
||||
var r services.GalleryFolderResponse
|
||||
if rename {
|
||||
r, err = services.RenameGalleryFolder(db, c.GetInt("user_id"), id, req.Name, descriptions...)
|
||||
} else {
|
||||
r, err = services.CreateGalleryFolder(db, c.GetInt("user_id"), req.Name, descriptions...)
|
||||
}
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("ok", r))
|
||||
}
|
||||
func DeleteFolder(c *gin.Context) {
|
||||
if !requireFolderAccount(c) {
|
||||
return
|
||||
}
|
||||
id, err := parseFolderID(c.Param("id"), false)
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeleteImages bool `json:"delete_images"`
|
||||
ConfirmName string `json:"confirm_name"`
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8<<10)
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(400, result.Error(400, "删除参数格式错误"))
|
||||
return
|
||||
}
|
||||
if req.DeleteImages {
|
||||
r, err := services.DeleteGalleryFolderWithImages(c.Request.Context(), database.GetDB().DB, c.GetInt("user_id"), id, req.ConfirmName)
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
if !r.Deleted {
|
||||
status, code, message := 409, "folder_delete_incomplete", "删除尚未完成,请继续删除剩余图片"
|
||||
if r.FailedCount > 0 {
|
||||
status, code, message = 502, "folder_delete_failed", "部分图片删除失败,已保留记录,请重试"
|
||||
}
|
||||
c.JSON(status, gin.H{"code": status, "message": message, "error_code": code, "data": r})
|
||||
return
|
||||
}
|
||||
c.JSON(200, result.Success("ok", r))
|
||||
return
|
||||
}
|
||||
if err := services.DeleteGalleryFolder(database.GetDB().DB.WithContext(c.Request.Context()), c.GetInt("user_id"), id); err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("ok", gin.H{}))
|
||||
}
|
||||
func MoveImagesFolder(c *gin.Context) {
|
||||
if !requireFolderAccount(c) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
var req struct {
|
||||
ImageIDs []int `json:"image_ids"`
|
||||
FolderID *int `json:"folder_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.FolderID == nil {
|
||||
c.JSON(400, result.Error(400, "图片或文件夹参数格式错误"))
|
||||
return
|
||||
}
|
||||
moved, err := services.MoveImagesToGalleryFolder(database.GetDB().DB.WithContext(c.Request.Context()), c.GetInt("user_id"), req.ImageIDs, *req.FolderID)
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("ok", gin.H{"moved_count": moved}))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"net/http"
|
||||
"oneimg/backend/utils/result"
|
||||
)
|
||||
|
||||
// internalFailure preserves operator diagnostics without exposing SQL/schema,
|
||||
// storage credentials, or upstream response internals to an authenticated user.
|
||||
func internalFailure(c *gin.Context, message string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", message, err)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, result.Error(http.StatusInternalServerError, message))
|
||||
}
|
||||
@@ -2,32 +2,22 @@ package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"fmt"
|
||||
"gorm.io/gorm/clause"
|
||||
"net/http"
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/uploadpolicy"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 初始化随机数种子
|
||||
var randomGenerator = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
// randomOrderExpr 返回当前数据库方言的随机排序函数。
|
||||
// SQLite / PostgreSQL 使用 RANDOM(),MySQL 使用 RAND()。
|
||||
func randomOrderExpr(db *gorm.DB) string {
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
return "RAND()"
|
||||
}
|
||||
return "RANDOM()"
|
||||
}
|
||||
|
||||
// 定义返回的图片结构体
|
||||
type RandomImageResponse struct {
|
||||
Image string `json:"image"` // 图片文件名
|
||||
@@ -39,8 +29,8 @@ func GetRandomImages(c *gin.Context) {
|
||||
model := c.DefaultQuery("model", "json")
|
||||
limitStr := c.DefaultQuery("limit", "1")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 1 || limit > 20 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "limit参数错误,必须是1-20之间的整数"))
|
||||
if err != nil || limit < 1 || limit > config.HardRandomImageLimit {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "limit参数错误,必须是1-100之间的整数"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,8 +40,22 @@ func GetRandomImages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if limit > uploadpolicy.RandomLimit(setting) {
|
||||
c.JSON(400, result.Error(400, fmt.Sprintf("limit参数错误,必须是1-%d之间的整数", uploadpolicy.RandomLimit(setting))))
|
||||
return
|
||||
}
|
||||
if model != "json" && model != "image" {
|
||||
c.JSON(400, result.Error(400, "model参数错误,可选 json 或 image"))
|
||||
return
|
||||
}
|
||||
if tag != "" {
|
||||
if err := uploadpolicy.ValidateTag(setting, tag); err != nil {
|
||||
c.JSON(400, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
if setting.RandomGraph == false {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "随机图功能未开启"))
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "随机图功能未开启"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,14 +65,13 @@ func GetRandomImages(c *gin.Context) {
|
||||
if err = db.Model(&models.RandomGraph{}).Where("id = ?", 1).Take(&randomGraph).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
randomGraph = models.RandomGraph{ID: 1, UserIds: []int{}, TagIds: []int{}}
|
||||
} else if strings.Contains(err.Error(), "cannot unmarshal") {
|
||||
randomGraph = models.RandomGraph{ID: 1, UserIds: []int{}, TagIds: []int{}}
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "获取随机图配置失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
query := db.Model(&models.Image{})
|
||||
query := db.Model(&models.Image{}).Where("images.deleting = ?", false)
|
||||
if len(randomGraph.UserIds) > 0 {
|
||||
query = query.Where("images.user_id IN ?", randomGraph.UserIds)
|
||||
}
|
||||
@@ -100,8 +103,8 @@ func GetRandomImages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var images []models.Image
|
||||
if err := query.Order(randomOrderExpr(db)).Limit(limit).Find(&images).Error; err != nil {
|
||||
images, err := sampleRandomImages(query, total, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "获取图片失败"))
|
||||
return
|
||||
}
|
||||
@@ -113,7 +116,7 @@ func GetRandomImages(c *gin.Context) {
|
||||
|
||||
if model == "image" {
|
||||
publicURL := images[0].Url
|
||||
if images[0].AccessBucketId == 0 {
|
||||
if images[0].AccessBucketId == 0 && !isHistoricalSVG(images[0], images[0].Url) {
|
||||
publicURL = applyPublicImageURL(setting, images[0].Storage, images[0].BucketId, images[0].Url)
|
||||
}
|
||||
if strings.HasPrefix(publicURL, "http://") || strings.HasPrefix(publicURL, "https://") {
|
||||
@@ -123,6 +126,7 @@ func GetRandomImages(c *gin.Context) {
|
||||
|
||||
originalPath := c.Request.URL.Path
|
||||
originalRawPath := c.Request.URL.RawPath
|
||||
defer func() { c.Request.URL.Path = originalPath; c.Request.URL.RawPath = originalRawPath }()
|
||||
imageURL := ensureLeadingSlash(images[0].Url)
|
||||
|
||||
c.Request.URL.Path = imageURL
|
||||
@@ -138,7 +142,7 @@ func GetRandomImages(c *gin.Context) {
|
||||
|
||||
var respData []RandomImageResponse
|
||||
for _, img := range images {
|
||||
fullUrl := buildImageResponseURL(c, setting, img.Storage, img.BucketId, img.Url)
|
||||
fullUrl := imagePublicResponseURL(c, setting, img)
|
||||
respData = append(respData, RandomImageResponse{
|
||||
Image: img.FileName,
|
||||
Url: fullUrl,
|
||||
@@ -186,6 +190,7 @@ func applyRandomGraphTagFilter(query *gorm.DB, db *gorm.DB, tagIds []int) *gorm.
|
||||
|
||||
// SetRandomGraph 设置随机图范围
|
||||
func SetRandomGraph(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
|
||||
var req models.RandomGraph
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "请求参数错误"))
|
||||
@@ -193,20 +198,19 @@ func SetRandomGraph(c *gin.Context) {
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
var err error
|
||||
if req.ID == 0 {
|
||||
err = db.Create(&req).Error
|
||||
} else {
|
||||
err = db.Model(&models.RandomGraph{}).Where("id = ?", req.ID).
|
||||
Select("UserIds", "TagIds").
|
||||
Updates(models.RandomGraph{
|
||||
UserIds: req.UserIds,
|
||||
TagIds: req.TagIds,
|
||||
}).Error
|
||||
if err == nil {
|
||||
db.First(&req, req.ID)
|
||||
// Singleton upsert is atomic; client IDs cannot create unread orphan config.
|
||||
req.ID = 1
|
||||
if len(req.UserIds) > 1000 || len(req.TagIds) > 1000 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "范围过大"))
|
||||
return
|
||||
}
|
||||
for _, id := range req.TagIds {
|
||||
if id < 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签ID无效"))
|
||||
return
|
||||
}
|
||||
}
|
||||
err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"user_ids", "tag_ids"})}).Create(&req).Error
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "设置随机图范围失败"))
|
||||
@@ -224,8 +228,7 @@ func GetRandomGraph(c *gin.Context) {
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
randomGraph = models.RandomGraph{UserIds: []int{}, TagIds: []int{}}
|
||||
} else if strings.Contains(err.Error(), "cannot unmarshal") {
|
||||
randomGraph = models.RandomGraph{ID: 1, UserIds: []int{}, TagIds: []int{}}
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "获取随机图范围失败"))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package controllers
|
||||
import (
|
||||
"errors"
|
||||
"oneimg/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
type resolvedImageAccess struct {
|
||||
bucket models.Buckets
|
||||
replica *models.ImageStorage
|
||||
storageType string
|
||||
path string
|
||||
}
|
||||
|
||||
func resolveImageAccess(db *gorm.DB, image models.Image, thumbnail bool) (resolvedImageAccess, error) {
|
||||
canonicalPath := image.Url
|
||||
if thumbnail {
|
||||
canonicalPath = image.Thumbnail
|
||||
}
|
||||
|
||||
// An explicitly selected source must have both an enabled bucket and a
|
||||
// successful replica. If it cannot currently serve the requested object,
|
||||
// transparently fall back to the durable local copy.
|
||||
if image.AccessBucketId > 0 {
|
||||
if resolved, ok := resolveImageReplicaAccess(db, image.Id, image.AccessBucketId, thumbnail); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
if resolved, ok := resolveLocalImageAccess(db, image.Id, thumbnail); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
} else if image.Storage != "default" {
|
||||
// Zero means the default access policy. Prefer a successful local
|
||||
// replica whenever one exists, including migrated legacy images whose
|
||||
// canonical record still points at a remote bucket.
|
||||
if resolved, ok := resolveLocalImageAccess(db, image.Id, thumbnail); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
|
||||
var canonicalBucket models.Buckets
|
||||
canonicalErr := db.First(&canonicalBucket, image.BucketId).Error
|
||||
if canonicalErr == nil && !canonicalBucket.Disabled && canonicalPath != "" {
|
||||
storageType := image.Storage
|
||||
if storageType == "" {
|
||||
storageType = canonicalBucket.Type
|
||||
}
|
||||
resolved := resolvedImageAccess{
|
||||
bucket: canonicalBucket,
|
||||
storageType: storageType,
|
||||
path: canonicalPath,
|
||||
}
|
||||
var replica models.ImageStorage
|
||||
if err := db.Where(
|
||||
"image_id = ? AND bucket_id = ? AND status = ?",
|
||||
image.Id, canonicalBucket.Id, models.ImageStorageStatusSuccess,
|
||||
).First(&replica).Error; err == nil {
|
||||
resolved.replica = &replica
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
if resolved, ok := resolveLocalImageAccess(db, image.Id, thumbnail); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
if canonicalErr != nil && !errors.Is(canonicalErr, gorm.ErrRecordNotFound) {
|
||||
return resolvedImageAccess{}, canonicalErr
|
||||
}
|
||||
return resolvedImageAccess{}, errors.New("没有可用的图片存储源")
|
||||
}
|
||||
|
||||
func resolveImageReplicaAccess(db *gorm.DB, imageID, bucketID int, thumbnail bool) (resolvedImageAccess, bool) {
|
||||
var replica models.ImageStorage
|
||||
if err := db.Where(
|
||||
"image_id = ? AND bucket_id = ? AND status = ?",
|
||||
imageID, bucketID, models.ImageStorageStatusSuccess,
|
||||
).First(&replica).Error; err != nil {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
|
||||
var bucket models.Buckets
|
||||
if err := db.Where("id = ? AND disabled = ?", bucketID, false).First(&bucket).Error; err != nil {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
path := replica.URL
|
||||
if thumbnail {
|
||||
path = replica.Thumbnail
|
||||
}
|
||||
if path == "" {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
storageType := replica.Storage
|
||||
if storageType == "" {
|
||||
storageType = bucket.Type
|
||||
}
|
||||
return resolvedImageAccess{bucket: bucket, replica: &replica, storageType: storageType, path: path}, true
|
||||
}
|
||||
|
||||
func resolveLocalImageAccess(db *gorm.DB, imageID int, thumbnail bool) (resolvedImageAccess, bool) {
|
||||
var replica models.ImageStorage
|
||||
if err := db.Model(&models.ImageStorage{}).
|
||||
Select("image_storages.*").
|
||||
Joins("JOIN buckets ON buckets.id = image_storages.bucket_id").
|
||||
Where(
|
||||
"image_storages.image_id = ? AND image_storages.status = ? AND buckets.type = ? AND buckets.disabled = ?",
|
||||
imageID, models.ImageStorageStatusSuccess, "default", false,
|
||||
).
|
||||
Order("buckets.id ASC").
|
||||
First(&replica).Error; err != nil {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
|
||||
var bucket models.Buckets
|
||||
if err := db.First(&bucket, replica.BucketID).Error; err != nil {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
path := replica.URL
|
||||
if thumbnail {
|
||||
path = replica.Thumbnail
|
||||
}
|
||||
if path == "" {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
return resolvedImageAccess{bucket: bucket, replica: &replica, storageType: "default", path: path}, true
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/settings"
|
||||
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
|
||||
// ImageWithTags 列表接口返回的图片及其标签、存储同步状态。
|
||||
type ImageWithTags struct {
|
||||
Deleting bool `json:"deleting" gorm:"column:deleting"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;column:id"`
|
||||
Url string `json:"url" gorm:"column:url"`
|
||||
Thumbnail string `json:"thumbnail" gorm:"column:thumbnail"`
|
||||
@@ -27,8 +29,10 @@ type ImageWithTags struct {
|
||||
Height int `json:"height" gorm:"column:height"`
|
||||
Storage string `json:"storage" gorm:"column:storage"`
|
||||
BucketId int `json:"bucket_id" gorm:"column:bucket_id"`
|
||||
FolderId int `json:"folder_id" gorm:"column:folder_id"`
|
||||
AccessBucketId int `json:"access_bucket_id" gorm:"column:access_bucket_id"`
|
||||
UserId int `json:"user_id" gorm:"column:user_id"`
|
||||
UploaderName string `json:"uploader_name" gorm:"-"`
|
||||
UploaderRole int `json:"uploader_role" gorm:"-"`
|
||||
Md5 string `json:"md5" gorm:"column:md5"`
|
||||
Uuid string `json:"uuid" gorm:"column:uuid"`
|
||||
@@ -42,8 +46,16 @@ func (ImageWithTags) TableName() string {
|
||||
return "images"
|
||||
}
|
||||
|
||||
// GetManagedImageList is the dedicated admin view; query parameters cannot
|
||||
// accidentally turn it into the personal folder browser.
|
||||
func GetManagedImageList(c *gin.Context) {
|
||||
c.Set("gallery_scope", "all")
|
||||
GetImageList(c)
|
||||
}
|
||||
|
||||
// GetImageList 分页获取图片列表(按角色过滤可见范围)。
|
||||
func GetImageList(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
@@ -68,13 +80,13 @@ func GetImageList(c *gin.Context) {
|
||||
if sortOrder != "asc" && sortOrder != "desc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
orderClause := dbSortField + " " + sortOrder
|
||||
orderClause := dbSortField + " " + sortOrder + ", images.id " + sortOrder
|
||||
|
||||
search := c.Query("search")
|
||||
roleFilter := c.Query("role")
|
||||
roleID := c.GetInt("user_role")
|
||||
userID := c.GetInt("user_id")
|
||||
userUUID := GetUUID(c)
|
||||
userUUID := CurrentOwnerKey(c)
|
||||
|
||||
var hasZeroTag bool
|
||||
var filterTagIds []int
|
||||
@@ -97,7 +109,32 @@ func GetImageList(c *gin.Context) {
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
idQuery := db.Model(&models.Image{}).Select("images.id", dbSortField)
|
||||
idQuery := db.Model(&models.Image{})
|
||||
if rawFolder, present := c.GetQuery("folder_id"); present {
|
||||
folderID, err := parseFolderID(rawFolder, true)
|
||||
if err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
if err := services.ValidateFolderRead(db.WithContext(c.Request.Context()), userID, roleID, folderID); err != nil {
|
||||
folderHTTPError(c, err)
|
||||
return
|
||||
}
|
||||
idQuery = idQuery.Where("images.folder_id = ? AND images.user_id = ?", folderID, userID)
|
||||
} else if _, present := c.Request.URL.Query()["folder_id"]; present {
|
||||
c.JSON(400, result.Error(400, "文件夹ID无效"))
|
||||
return
|
||||
}
|
||||
switch c.DefaultQuery("deletion", "active") {
|
||||
case "active":
|
||||
idQuery = idQuery.Where("images.deleting = ?", false)
|
||||
case "deleting":
|
||||
idQuery = idQuery.Where("images.deleting = ?", true)
|
||||
case "all": // Explicit management list; owner/role permissions below still apply.
|
||||
default:
|
||||
c.JSON(400, result.Error(400, "删除状态筛选无效"))
|
||||
return
|
||||
}
|
||||
|
||||
// 存储桶筛选
|
||||
bucket := c.Query("bucket")
|
||||
@@ -108,34 +145,46 @@ func GetImageList(c *gin.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// 仅超级管理员可使用 role=admin / role=guest 全局筛选
|
||||
if roleFilter != "" {
|
||||
if roleID != models.RoleAdmin {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "无权限查看全局用户图片"))
|
||||
// Explicit personal scope never expands, even if a stale role filter is sent.
|
||||
scope := c.Query("scope")
|
||||
if fixed := c.GetString("gallery_scope"); fixed != "" {
|
||||
scope = fixed
|
||||
}
|
||||
if scope != "" && scope != "mine" && scope != "all" {
|
||||
c.JSON(400, result.Error(400, "图库范围无效"))
|
||||
return
|
||||
}
|
||||
global := scope == "all" || (scope == "" && roleFilter != "")
|
||||
if global {
|
||||
if roleID != models.RoleAdmin || userID <= 0 {
|
||||
status := 403
|
||||
if scope == "" {
|
||||
status = 400
|
||||
} // Legacy clients keep their error contract.
|
||||
c.JSON(status, result.Error(status, "仅管理员可查看总图库"))
|
||||
return
|
||||
}
|
||||
switch roleFilter {
|
||||
case "", "all":
|
||||
case "admin":
|
||||
idQuery = idQuery.Where("images.user_id = ?", userID)
|
||||
case "guest":
|
||||
idQuery = idQuery.Joins("LEFT JOIN users ON images.user_id = users.id").
|
||||
Where("users.id IS NULL")
|
||||
idQuery = idQuery.Where("EXISTS (SELECT 1 FROM users WHERE users.id = images.user_id AND users.role = ?)", models.RoleAdmin)
|
||||
case "user":
|
||||
idQuery = idQuery.Joins("LEFT JOIN users ON images.user_id = users.id").
|
||||
Where("users.id IS NOT NULL")
|
||||
if userID != models.SuperAdminID {
|
||||
idQuery = idQuery.Where("images.user_id = ? and users.id != ?", models.SuperAdminID, userID)
|
||||
} else {
|
||||
idQuery = idQuery.Where("images.user_id != ? or users.id != ?", models.SuperAdminID, userID)
|
||||
}
|
||||
idQuery = idQuery.Where("EXISTS (SELECT 1 FROM users WHERE users.id = images.user_id AND users.role = ?)", models.RoleUser)
|
||||
case "guest":
|
||||
idQuery = idQuery.Where("images.user_id < 0 OR NOT EXISTS (SELECT 1 FROM users WHERE users.id = images.user_id) OR EXISTS (SELECT 1 FROM users WHERE users.id = images.user_id AND users.role = ?)", models.RoleGuest)
|
||||
default:
|
||||
c.JSON(400, result.Error(400, "上传者角色无效"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 普通角色:只能查看自身数据
|
||||
switch roleID {
|
||||
case models.RoleUser, models.RoleAdmin:
|
||||
idQuery = idQuery.Where("images.user_id = ?", userID)
|
||||
idQuery = idQuery.Where("images.user_id = ? AND images.user_id > 0", userID)
|
||||
case models.RoleGuest:
|
||||
idQuery = idQuery.Where("images.uuid = ?", userUUID)
|
||||
idQuery = scopeGuestImages(idQuery, userID, userUUID)
|
||||
default:
|
||||
c.JSON(403, result.Error(403, "无权查看图库"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,43 +192,28 @@ func GetImageList(c *gin.Context) {
|
||||
idQuery = idQuery.Where("images.file_name LIKE ?", "%"+search+"%")
|
||||
}
|
||||
|
||||
if hasZeroTag || len(filterTagIds) > 0 {
|
||||
idQuery = idQuery.Joins("LEFT JOIN image_to_tags ON images.id = image_to_tags.image_id")
|
||||
if hasZeroTag && len(filterTagIds) > 0 {
|
||||
idQuery = idQuery.Where("image_to_tags.tag_id IS NULL OR image_to_tags.tag_id IN (?)", filterTagIds)
|
||||
} else if hasZeroTag {
|
||||
idQuery = idQuery.Where("image_to_tags.tag_id IS NULL")
|
||||
} else {
|
||||
idQuery = idQuery.Where("image_to_tags.tag_id IN (?) AND image_to_tags.tag_id IS NOT NULL", filterTagIds)
|
||||
}
|
||||
idQuery = idQuery.Distinct("images.id")
|
||||
}
|
||||
|
||||
var imageIds []int
|
||||
if err := idQuery.Order(orderClause).Offset(offset).Limit(limit).Pluck("images.id", &imageIds).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "筛选图片失败:"+err.Error()))
|
||||
idQuery = filterImageTags(idQuery, filterTagIds, hasZeroTag)
|
||||
var total int64
|
||||
if err := idQuery.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "统计图片失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
countQuery := idQuery.Session(&gorm.Session{})
|
||||
countQuery = countQuery.Offset(-1).Limit(-1)
|
||||
if hasZeroTag || len(filterTagIds) > 0 {
|
||||
countQuery.Distinct("images.id").Count(&total)
|
||||
} else {
|
||||
countQuery.Count(&total)
|
||||
var imageIds []int
|
||||
if err := idQuery.Session(&gorm.Session{}).Order(orderClause).Offset(offset).Limit(limit).Pluck("images.id", &imageIds).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "筛选图片失败"))
|
||||
return
|
||||
}
|
||||
totalPages := (total + int64(limit) - 1) / int64(limit)
|
||||
|
||||
var images []ImageWithTags
|
||||
if len(imageIds) > 0 {
|
||||
imageFields := "id, url, thumbnail, file_name, file_size, mime_type, width, height, storage, bucket_id, access_bucket_id, user_id, md5, uuid, created_at"
|
||||
imageFields := "id, deleting, url, thumbnail, file_name, file_size, mime_type, width, height, storage, bucket_id, access_bucket_id, folder_id, user_id, md5, uuid, created_at"
|
||||
if err := db.Model(&models.Image{}).
|
||||
Select(imageFields).
|
||||
Where("id IN (?)", imageIds).
|
||||
Order(orderClause).
|
||||
Find(&images).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询图片详情失败:"+err.Error()))
|
||||
internalFailure(c, "查询图片详情失败", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -193,7 +227,7 @@ func GetImageList(c *gin.Context) {
|
||||
var imageToTags []models.ImageToTags
|
||||
if len(imgIds) > 0 {
|
||||
if err := db.Where("image_id IN (?)", imgIds).Find(&imageToTags).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询标签关联失败:"+err.Error()))
|
||||
internalFailure(c, "查询标签关联失败", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -247,19 +281,22 @@ func GetImageList(c *gin.Context) {
|
||||
|
||||
// 批量查询用户ID-角色映射
|
||||
type userRoleDTO struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Role int `gorm:"column:role"`
|
||||
ID int `gorm:"column:id"`
|
||||
Role int `gorm:"column:role"`
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
var userRoleList []userRoleDTO
|
||||
roleMap := make(map[int]int)
|
||||
nameMap := make(map[int]string)
|
||||
if len(uidList) > 0 {
|
||||
err := db.Model(&models.User{}).
|
||||
Select("id, role").
|
||||
Select("id, role, username").
|
||||
Where("id IN (?)", uidList).
|
||||
Find(&userRoleList).Error
|
||||
if err == nil {
|
||||
for _, item := range userRoleList {
|
||||
roleMap[item.ID] = item.Role
|
||||
nameMap[item.ID] = item.Username
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,19 +306,21 @@ func GetImageList(c *gin.Context) {
|
||||
uid := images[i].UserId
|
||||
if r, exist := roleMap[uid]; exist {
|
||||
images[i].UploaderRole = r
|
||||
images[i].UploaderName = nameMap[uid]
|
||||
} else {
|
||||
images[i].UploaderRole = models.RoleGuest
|
||||
images[i].UploaderName = "游客"
|
||||
}
|
||||
}
|
||||
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "获取系统配置失败:"+err.Error()))
|
||||
internalFailure(c, "获取系统配置失败", err)
|
||||
return
|
||||
}
|
||||
storageStatuses, err := loadImageStorageStatuses(imgIds, setting)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "获取存储同步状态失败:"+err.Error()))
|
||||
internalFailure(c, "获取存储同步状态失败", err)
|
||||
return
|
||||
}
|
||||
for i := range images {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"math/big"
|
||||
"oneimg/backend/models"
|
||||
)
|
||||
|
||||
// Uniform sample without replacement. Ordered primary-key offsets avoid a full
|
||||
// random sort and preserve fairness across sparse/deleted IDs. Bounded by configured random_image_limit.
|
||||
func sampleRandomImages(query *gorm.DB, total int64, limit int) ([]models.Image, error) {
|
||||
if int64(limit) > total {
|
||||
limit = int(total)
|
||||
}
|
||||
chosen := make(map[int64]bool, limit)
|
||||
offsets := make([]int64, 0, limit)
|
||||
for j := total - int64(limit); j < total; j++ {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(j+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := n.Int64()
|
||||
if chosen[k] {
|
||||
k = j
|
||||
}
|
||||
chosen[k] = true
|
||||
offsets = append(offsets, k)
|
||||
}
|
||||
result := make([]models.Image, 0, limit)
|
||||
for _, offset := range offsets {
|
||||
var image models.Image
|
||||
err := query.Session(&gorm.Session{}).Order("images.id ASC").Offset(int(offset)).Limit(1).Take(&image).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, image)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func imagePublicResponseURL(c *gin.Context, s models.Settings, image models.Image) string {
|
||||
if image.AccessBucketId > 0 || isHistoricalSVG(image, image.Url) {
|
||||
return getRequestBaseURL(c) + ensureLeadingSlash(image.Url)
|
||||
}
|
||||
return buildImageResponseURL(c, s, image.Storage, image.BucketId, image.Url)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package controllers
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"oneimg/backend/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
// 辅助函数,校验来源
|
||||
func checkReferer(referer, whiteList, selfDomain string) bool {
|
||||
return checkRefererPolicy(referer, whiteList, selfDomain, true)
|
||||
}
|
||||
func checkRefererPolicy(referer, whiteList, selfDomain string, allowEmpty bool) bool {
|
||||
if strings.TrimSpace(referer) == "" { return allowEmpty }
|
||||
domain, err := extractDomainFromReferer(referer)
|
||||
if err != nil { return false }
|
||||
allowed := append(strings.Split(whiteList, ","), selfDomain)
|
||||
for _, raw := range allowed {
|
||||
host := strings.ToLower(strings.TrimSpace(raw))
|
||||
if host == "" || strings.ContainsAny(host, "/:@?# \r\n") { continue }
|
||||
if domain == host || strings.HasSuffix(domain, "."+host) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
func extractDomainFromReferer(referer string) (string, error) {
|
||||
u, err := url.Parse(referer)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.Hostname() == "" { return "", errors.New("invalid referer") }
|
||||
return strings.ToLower(u.Hostname()), nil
|
||||
}
|
||||
// Never derive a trust boundary from client-supplied Host/Forwarded headers.
|
||||
func GetSelfDomain(c *gin.Context) string {
|
||||
if config.App == nil { return "" }
|
||||
host, err := extractDomainFromReferer(strings.TrimSpace(config.App.AppURL))
|
||||
if err != nil { return "" }
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"oneimg/backend/models"
|
||||
)
|
||||
|
||||
func validGuestUUID(value string) bool {
|
||||
parsed,err:=uuid.Parse(value)
|
||||
return err==nil && parsed!=uuid.Nil && parsed.Version()==4 && parsed.String()==value
|
||||
}
|
||||
func scopeGuestImages(query *gorm.DB, userID int, fingerprint string) *gorm.DB {
|
||||
if userID >= 0 || !validGuestUUID(fingerprint) { return query.Where("1 = 0") }
|
||||
return query.Where("images.user_id = ? AND images.uuid = ?",userID,fingerprint)
|
||||
}
|
||||
func guestOwnsImage(c *gin.Context, image models.Image) bool {
|
||||
fingerprint:=CurrentOwnerKey(c)
|
||||
return c.GetInt("user_role")==models.RoleGuest && c.GetInt("user_id")<0 && image.UserId==c.GetInt("user_id") && validGuestUUID(fingerprint) && image.UUID==fingerprint
|
||||
}
|
||||
// EXISTS avoids join multiplicity and PostgreSQL DISTINCT/ORDER BY coupling.
|
||||
func filterImageTags(query *gorm.DB, ids []int, untagged bool) *gorm.DB {
|
||||
exists:="EXISTS (SELECT 1 FROM image_to_tags WHERE image_to_tags.image_id = images.id AND image_to_tags.tag_id IN ?)"
|
||||
noTags:="NOT EXISTS (SELECT 1 FROM image_to_tags WHERE image_to_tags.image_id = images.id)"
|
||||
if untagged && len(ids)>0 { return query.Where("("+noTags+" OR "+exists+")",ids) }
|
||||
if untagged { return query.Where(noTags) }
|
||||
if len(ids)>0 { return query.Where(exists,ids) }
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/securestorage"
|
||||
"oneimg/backend/utils/watermark"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxProcessedObjectBytes int64 = 32 << 20
|
||||
func unsafeImageExtension(path string)bool {ext:=strings.ToLower(filepath.Ext(path));return ext==".svg" || ext==".svgz"}
|
||||
var encryptedReadSlots = make(chan struct{}, 2)
|
||||
|
||||
func isHistoricalSVG(image models.Image, objectPath string) bool {
|
||||
return strings.EqualFold(strings.SplitN(image.MimeType, ";", 2)[0], "image/svg+xml") || unsafeImageExtension(objectPath)
|
||||
}
|
||||
func imageResponseHeaders(c *gin.Context, mimeType, storage string, unsafe bool) {
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("X-Storage-Type", storage)
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
if unsafe {
|
||||
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
|
||||
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename":filepath.Base(c.Request.URL.Path)}))
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
}
|
||||
}
|
||||
func imagePayloadType(prefix []byte, declared string, path string) (string, bool) {
|
||||
actual := http.DetectContentType(prefix)
|
||||
// Safe raster magic only. Everything else, including historical SVG whose
|
||||
// metadata lies, is sandboxed and downloaded rather than rendered inline.
|
||||
raster := actual == "image/png" || actual == "image/jpeg" || actual == "image/gif" || actual == "image/webp"
|
||||
historicalSVG := strings.EqualFold(strings.SplitN(declared, ";", 2)[0], "image/svg+xml") || unsafeImageExtension(path)
|
||||
if raster && !historicalSVG { return actual, false }
|
||||
if historicalSVG { return "image/svg+xml", true }
|
||||
return "application/octet-stream", true
|
||||
}
|
||||
|
||||
// Plain seekable objects are served without a whole-file allocation. Streaming
|
||||
// drivers only spool to bounded temporary disk when Range needs random access.
|
||||
// GCM objects authenticate in bounded memory before returning any plaintext.
|
||||
func serveStoredImage(c *gin.Context, stored io.Reader, declared, storage string, wm watermark.WatermarkConfig) error {
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
c.Header("Allow", "GET, HEAD"); c.AbortWithStatus(http.StatusMethodNotAllowed); return nil
|
||||
}
|
||||
reader := bufio.NewReaderSize(stored, 4096)
|
||||
prefix, err := reader.Peek(512)
|
||||
if err != nil && !errors.Is(err, io.EOF) { return err }
|
||||
var payload io.Reader = reader
|
||||
var seeker io.ReadSeeker
|
||||
if securestorage.IsEncrypted(prefix) {
|
||||
select { case encryptedReadSlots <- struct{}{}: case <-c.Request.Context().Done(): return c.Request.Context().Err() }
|
||||
defer func(){ <-encryptedReadSlots }()
|
||||
content, _, err := securestorage.ReadAllLimited(reader, maxProcessedObjectBytes+64)
|
||||
if err != nil { return err }
|
||||
payload = bytes.NewReader(content)
|
||||
seeker = payload.(io.ReadSeeker)
|
||||
prefix = content
|
||||
if len(prefix)>512 { prefix=prefix[:512] }
|
||||
}
|
||||
actual, unsafe := imagePayloadType(prefix, declared, c.Request.URL.Path)
|
||||
imageResponseHeaders(c, actual, storage, unsafe)
|
||||
needRandomAccess := c.Request.Header.Get("Range") != "" || c.Request.Method == http.MethodHead
|
||||
if wm.Enable && !unsafe {
|
||||
transformed, err := watermark.ProcessImageWithWatermark(payload, actual, wm)
|
||||
if err == nil {
|
||||
data, err := io.ReadAll(io.LimitReader(transformed, maxProcessedObjectBytes+1))
|
||||
if err != nil { return err }; if int64(len(data)) > maxProcessedObjectBytes { return securestorage.ErrObjectTooLarge }
|
||||
payload = bytes.NewReader(data); seeker=payload.(io.ReadSeeker)
|
||||
imageResponseHeaders(c, http.DetectContentType(data), storage, false)
|
||||
} else {
|
||||
// No silently corrupted/empty fallback after consuming a stream.
|
||||
return err
|
||||
}
|
||||
}
|
||||
if seeker != nil {
|
||||
http.ServeContent(c.Writer,c.Request,filepath.Base(c.Request.URL.Path),time.Time{},seeker)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return nil
|
||||
}
|
||||
if needRandomAccess {
|
||||
if rs, ok := stored.(io.ReadSeeker); ok && !wm.Enable {
|
||||
// Re-read from the start: reader has already consumed the peeked prefix.
|
||||
if _, err := rs.Seek(0, io.SeekStart); err != nil { return err }
|
||||
http.ServeContent(c.Writer,c.Request,filepath.Base(c.Request.URL.Path),time.Time{},rs)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return nil
|
||||
}
|
||||
f, err := os.CreateTemp("", "oneimg-range-*")
|
||||
if err != nil { return err }; defer os.Remove(f.Name()); defer f.Close()
|
||||
n,err:=io.Copy(f,io.LimitReader(payload,maxProcessedObjectBytes+1))
|
||||
if err!=nil { return err }; if n>maxProcessedObjectBytes { return securestorage.ErrObjectTooLarge }
|
||||
if _,err=f.Seek(0,io.SeekStart);err!=nil{return err}
|
||||
http.ServeContent(c.Writer,c.Request,filepath.Base(c.Request.URL.Path),time.Time{},f)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return nil
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
_,err=io.Copy(c.Writer,payload)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package controllers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/result"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
// AddImageTag 单个添加图片标签
|
||||
func AddImageTag(c *gin.Context) {
|
||||
type TagRequest struct {
|
||||
Id int `json:"id"` // 图片ID
|
||||
Tag string `json:"tag"` // 标签ID(前端传字符串,后端转换)
|
||||
}
|
||||
|
||||
var req TagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Id <= 0 || req.Tag == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
tagId, err := strconv.Atoi(req.Tag)
|
||||
if err != nil || tagId <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签ID无效"))
|
||||
return
|
||||
}
|
||||
imageId := req.Id
|
||||
|
||||
db := database.GetDB().DB
|
||||
|
||||
// 1. 查询图片是否存在
|
||||
var image models.Image
|
||||
if err := db.Where("id = ?", imageId).First(&image).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "图片不存在"))
|
||||
} else {
|
||||
internalFailure(c, "查询图片失败", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 校验图片操作权限
|
||||
if !CheckImageAccessPermission(c, image, "image:tag:add") {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "无权操作"))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 查询标签是否存在
|
||||
var tag models.Tags
|
||||
if err := db.Where("id = ?", tagId).First(&tag).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签不存在"))
|
||||
} else {
|
||||
internalFailure(c, "查询标签失败", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 查询图片是否已经添加过该标签
|
||||
var imageTag models.ImageToTags
|
||||
if err := db.Where("image_id = ? AND tag_id = ?", imageId, tagId).First(&imageTag).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "图片已添加过该标签"))
|
||||
return
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
internalFailure(c, "检查标签关联失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 添加图片标签关联
|
||||
if err := db.Create(&models.ImageToTags{
|
||||
ImageId: imageId,
|
||||
TagId: tagId,
|
||||
}).Error; err != nil {
|
||||
internalFailure(c, "添加标签失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("标签添加成功", nil))
|
||||
}
|
||||
|
||||
// DeleteImageTag 单个删除图片标签
|
||||
func DeleteImageTag(c *gin.Context) {
|
||||
type TagRequest struct {
|
||||
Id int `json:"id"` // 图片ID
|
||||
Tag int `json:"tag"` // 标签ID
|
||||
}
|
||||
|
||||
var req TagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Id <= 0 || req.Tag <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
tagId := req.Tag
|
||||
imageId := req.Id
|
||||
db := database.GetDB().DB
|
||||
|
||||
// 1. 查询图片是否存在
|
||||
var image models.Image
|
||||
if err := db.Where("id = ?", imageId).First(&image).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "图片不存在"))
|
||||
} else {
|
||||
internalFailure(c, "查询图片失败", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 校验图片操作权限
|
||||
if !CheckImageAccessPermission(c, image, "image:tag:delete") {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "无权操作"))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 检查标签是否已经添加过该图片
|
||||
var imageTag models.ImageToTags
|
||||
if err := db.Where("image_id = ? AND tag_id = ?", imageId, tagId).First(&imageTag).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "关联不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 删除图片标签关联
|
||||
if err := db.Delete(&imageTag).Error; err != nil {
|
||||
internalFailure(c, "删除标签失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("标签删除成功", nil))
|
||||
}
|
||||
|
||||
// DeleteImageTags 批量删除图片标签
|
||||
func DeleteImageTags(c *gin.Context) {
|
||||
type Request struct {
|
||||
Images []int `json:"image_ids"`
|
||||
Tag string `json:"tag_id"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := strconv.Atoi(req.Tag)
|
||||
if err != nil || len(req.Images) <= 0 || tagID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
|
||||
// 查询所有目标图片记录并校验权限
|
||||
var images []models.Image
|
||||
if err := db.Where("id IN ?", req.Images).Find(&images).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询图片数据失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 核对查询到的图片数量,防止前端传入不存在的图片ID
|
||||
if len(images) == 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "指定图片不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var validImageIDs []int
|
||||
for _, img := range images {
|
||||
// 校验每一张图片的操作权限
|
||||
if !CheckImageAccessPermission(c, img, "image:tag:delete") {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "无权操作部分或全部图片"))
|
||||
return
|
||||
}
|
||||
validImageIDs = append(validImageIDs, int(img.Id)) // 收集有效ID
|
||||
}
|
||||
|
||||
// 直接使用单条SQL执行批量删除,避免for循环中执行多条SQL
|
||||
if err := db.Where("image_id IN ? AND tag_id = ?", validImageIDs, tagID).Delete(&models.ImageToTags{}).Error; err != nil {
|
||||
internalFailure(c, "批量删除标签失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("批量删除标签成功", nil))
|
||||
}
|
||||
|
||||
// AddImageTags 批量添加图片标签
|
||||
func AddImageTags(c *gin.Context) {
|
||||
type Request struct {
|
||||
Images []int `json:"image_ids"`
|
||||
Tag string `json:"tag_id"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := strconv.Atoi(req.Tag)
|
||||
if err != nil || len(req.Images) <= 0 || tagID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
|
||||
// 1. 检查标签是否存在
|
||||
var tag models.Tags
|
||||
if err := db.Where("id = ?", tagID).First(&tag).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签不存在"))
|
||||
} else {
|
||||
internalFailure(c, "查询标签失败", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 查询并校验图片列表权限
|
||||
var images []models.Image
|
||||
if err := db.Where("id IN ?", req.Images).Find(&images).Error; err != nil {
|
||||
internalFailure(c, "查询图片列表失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
var validImageIDs []int
|
||||
for _, img := range images {
|
||||
// 校验每张图的权限
|
||||
if !CheckImageAccessPermission(c, img, "image:tag:add") {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "无权操作部分或全部图片"))
|
||||
return
|
||||
}
|
||||
validImageIDs = append(validImageIDs, int(img.Id))
|
||||
}
|
||||
|
||||
if len(validImageIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "无效的图片ID"))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 筛选出已经存在关联的记录,防止重复添加
|
||||
var existRelations []int
|
||||
if err := db.Model(&models.ImageToTags{}).
|
||||
Where("image_id IN ? AND tag_id = ?", validImageIDs, tagID).
|
||||
Pluck("image_id", &existRelations).Error; err != nil {
|
||||
internalFailure(c, "检查标签关联失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
existRelationMap := make(map[int]bool)
|
||||
for _, id := range existRelations {
|
||||
existRelationMap[id] = true
|
||||
}
|
||||
|
||||
// 4. 构建需要插入的新关联数据
|
||||
var insertData []models.ImageToTags
|
||||
for _, imageID := range validImageIDs {
|
||||
if existRelationMap[imageID] {
|
||||
continue // 如果已存在则跳过
|
||||
}
|
||||
insertData = append(insertData, models.ImageToTags{
|
||||
ImageId: imageID,
|
||||
TagId: tagID,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 批量插入
|
||||
if len(insertData) > 0 {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.CreateInBatches(&insertData, 100).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
internalFailure(c, "批量添加标签失败", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, result.Success("没有需要添加的标签(可能已全部存在)", nil))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("批量添加标签成功", nil))
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"oneimg/backend/config"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -9,8 +11,8 @@ import (
|
||||
"oneimg/backend/utils/publicurl"
|
||||
)
|
||||
|
||||
func buildImageResponseURL(c *gin.Context, setting models.Settings, storage string, bucketID int, path string) string {
|
||||
publicPath := publicurl.BuildForStorage(setting, storage, bucketID, path)
|
||||
func buildImageResponseURL(c *gin.Context, setting models.Settings, storageType string, bucketID int, imagePath string) string {
|
||||
publicPath := applyPublicImageURL(setting, storageType, bucketID, imagePath)
|
||||
if publicPath == "" || strings.HasPrefix(publicPath, "http://") || strings.HasPrefix(publicPath, "https://") {
|
||||
return publicPath
|
||||
}
|
||||
@@ -30,19 +32,14 @@ func rewriteImageURLs(setting models.Settings, image *models.Image) {
|
||||
}
|
||||
|
||||
func getRequestBaseURL(c *gin.Context) string {
|
||||
scheme := "http"
|
||||
if proto := firstForwardedValue(c.GetHeader("X-Forwarded-Proto")); proto != "" {
|
||||
scheme = proto
|
||||
} else if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
if config.App != nil {
|
||||
u, err := url.Parse(strings.TrimSpace(config.App.AppURL))
|
||||
if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && u.User == nil && u.RawQuery == "" && u.Fragment == "" {
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
}
|
||||
}
|
||||
|
||||
host := c.Request.Host
|
||||
if forwardedHost := firstForwardedValue(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(scheme+"://"+host, "/")
|
||||
// Invalid/missing AppURL produces a relative URL, not an attacker-host URL.
|
||||
return ""
|
||||
}
|
||||
|
||||
func ensureLeadingSlash(path string) string {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/images"
|
||||
"oneimg/backend/utils/md5"
|
||||
"oneimg/backend/utils/safefetch"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/telegram"
|
||||
"oneimg/backend/utils/uploads"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fetchUploadURL is injectable for isolated controller tests; production keeps
|
||||
// safefetch's DNS, redirect, and private-address defenses.
|
||||
var fetchUploadURL = safefetch.Get
|
||||
|
||||
// 通过URL上传图片
|
||||
func UploadImagesByURL(c *gin.Context) {
|
||||
release, ok := acquireUploadRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
|
||||
defer func() {
|
||||
if c.Request.MultipartForm != nil {
|
||||
_ = c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
uc := uploads.NewUploadContext(c)
|
||||
if c.GetInt("user_role") == models.RoleGuest && (c.GetInt("user_id") >= 0 || !validGuestUUID(CurrentOwnerKey(c))) {
|
||||
uc.Fail(403, "游客身份无效")
|
||||
return
|
||||
}
|
||||
db := database.GetDB()
|
||||
|
||||
type URLUploadRequest struct {
|
||||
Urls string `json:"url" binding:"required"`
|
||||
Tag string `json:"tag_id"`
|
||||
FolderID int `json:"folder_id"`
|
||||
BucketID string `json:"bucket_id"` // 兼容旧客户端,目标存储源以用户配置为准。
|
||||
}
|
||||
|
||||
var req URLUploadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
uc.Fail(400, "参数格式错误:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !validateUploadFolder(c, req.FolderID) {
|
||||
return
|
||||
}
|
||||
if req.Urls == "" {
|
||||
uc.Fail(400, "URL不能为空")
|
||||
return
|
||||
}
|
||||
if req.Tag != "" && req.Tag != "0" {
|
||||
var tags models.Tags
|
||||
if err := db.DB.Where("id = ?", req.Tag).First(&tags).Error; err != nil {
|
||||
uc.Fail(400, "标签不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
uc.Fail(500, "获取上传配置失败:%v", err)
|
||||
return
|
||||
}
|
||||
if !setting.MultiStorageSync {
|
||||
uploadImageByURLLegacy(c, setting, req.Urls, req.Tag, req.BucketID, req.FolderID)
|
||||
return
|
||||
}
|
||||
|
||||
localBucket, syncBuckets, err := resolveUploadBuckets(c, setting)
|
||||
if err != nil {
|
||||
uc.Fail(500, "获取用户同步存储源失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 下载图片(拒绝内网/metadata,防止 SSRF)
|
||||
if err := safefetch.ValidatePublicHTTPURL(req.Urls); err != nil {
|
||||
uc.Fail(400, "URL 不合法或禁止访问内网地址")
|
||||
return
|
||||
}
|
||||
resp, err := fetchUploadURL(c.Request.Context(), req.Urls, 60*time.Second)
|
||||
if err != nil {
|
||||
uc.Fail(400, "图片下载失败:%v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
uc.Fail(400, "图片下载失败,远端状态码:%d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
fileName := filepath.Base(req.Urls)
|
||||
if fileName == "/" || fileName == "." || fileName == "" {
|
||||
fileName = fmt.Sprintf("url_image_%d.jpg", time.Now().Unix())
|
||||
}
|
||||
fileName = filepath.Base(strings.ReplaceAll(fileName, "\\", "/"))
|
||||
if strings.Contains(fileName, "..") || fileName == "." || fileName == "" {
|
||||
fileName = fmt.Sprintf("url_image_%d.jpg", time.Now().Unix())
|
||||
}
|
||||
|
||||
maxBytes := int64(setting.MaxFileSize)
|
||||
if maxBytes <= 0 || maxBytes > images.MaxUploadBytes {
|
||||
maxBytes = images.MaxUploadBytes
|
||||
}
|
||||
fileBytes, err := safefetch.ReadLimited(resp.Body, maxBytes)
|
||||
if err != nil {
|
||||
uc.Fail(http.StatusBadRequest, "URL 图片超过限制或下载失败")
|
||||
return
|
||||
}
|
||||
info, err := inspectDownloadedImage(fileBytes)
|
||||
if err != nil || !images.AllowedMIME(info.MIME, strings.Split(setting.AllowedTypes, ",")) {
|
||||
uc.Fail(http.StatusBadRequest, "URL不是允许的安全图片")
|
||||
return
|
||||
}
|
||||
contentType := info.MIME
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
part, _ := writer.CreatePart(map[string][]string{
|
||||
"Content-Disposition": {`form-data; name="file"; filename="` + fileName + `"`},
|
||||
"Content-Type": {contentType},
|
||||
})
|
||||
part.Write(fileBytes)
|
||||
writer.Close()
|
||||
|
||||
// 伪装请求
|
||||
c.Request.Body = io.NopCloser(body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
c.Request.ContentLength = int64(body.Len())
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
uc.Fail(500, "构造文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
uploader, err := uc.GetStorageUploader(&setting, &localBucket)
|
||||
if err != nil {
|
||||
uc.Fail(500, "初始化本机存储失败:%s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fileResult, err := uploader.Upload(c, &setting, &localBucket, header)
|
||||
if err != nil {
|
||||
uc.Fail(500, "保存到本机失败[%s]:%v", fileName, err)
|
||||
return
|
||||
}
|
||||
|
||||
imageModel := models.Image{
|
||||
Url: fileResult.URL,
|
||||
Thumbnail: fileResult.ThumbnailURL,
|
||||
FileName: fileResult.FileName,
|
||||
FileSize: fileResult.FileSize,
|
||||
MimeType: fileResult.MimeType,
|
||||
Width: fileResult.Width,
|
||||
Height: fileResult.Height,
|
||||
Storage: fileResult.Storage,
|
||||
BucketId: localBucket.Id,
|
||||
FolderId: req.FolderID,
|
||||
UserId: c.GetInt("user_id"),
|
||||
MD5: md5.Md5(c.GetString("username") + fileResult.FileName),
|
||||
UUID: CurrentOwnerKey(c),
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
resolved, err := services.ResolvePublicationFolder(tx, imageModel.UserId, imageModel.FolderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imageModel.FolderId = resolved
|
||||
if err := tx.Create(&imageModel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
localStatus := models.ImageStorage{
|
||||
ImageID: imageModel.Id,
|
||||
BucketID: localBucket.Id,
|
||||
Storage: localBucket.Type,
|
||||
Status: models.ImageStorageStatusSuccess,
|
||||
URL: fileResult.URL,
|
||||
Thumbnail: fileResult.ThumbnailURL,
|
||||
FileSize: fileResult.FileSize,
|
||||
ThumbnailSize: fileResult.ThumbnailSize,
|
||||
SyncedAt: &now,
|
||||
}
|
||||
if err := tx.Create(&localStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, bucket := range syncBuckets {
|
||||
storageStatus := models.ImageStorage{
|
||||
ImageID: imageModel.Id,
|
||||
BucketID: bucket.Id,
|
||||
Storage: bucket.Type,
|
||||
Status: models.ImageStorageStatusPending,
|
||||
URL: fileResult.URL,
|
||||
Thumbnail: fileResult.ThumbnailURL,
|
||||
FileSize: fileResult.FileSize,
|
||||
ThumbnailSize: fileResult.ThumbnailSize,
|
||||
}
|
||||
if err := tx.Create(&storageStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if req.Tag != "" && req.Tag != "0" {
|
||||
tagID, conversionErr := strconv.Atoi(req.Tag)
|
||||
if conversionErr != nil {
|
||||
return conversionErr
|
||||
}
|
||||
if err := tx.Create(&models.ImageToTags{ImageId: imageModel.Id, TagId: tagID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return chargeUploadQuota(tx, localBucket, fileResult)
|
||||
})
|
||||
if err != nil {
|
||||
cleanupLocalUpload(imageModel)
|
||||
uc.Fail(500, "保存文件记录失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// TG通知
|
||||
if setting.TGNotice {
|
||||
placeholderData := telegram.PlaceholderData{
|
||||
Username: c.GetString("username"),
|
||||
Date: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Filename: fileResult.FileName,
|
||||
StorageType: localBucket.Type,
|
||||
URL: buildImageResponseURL(c, setting, localBucket.Type, localBucket.Id, fileResult.URL),
|
||||
}
|
||||
if err := telegram.SendSimpleMsg(setting.TGBotToken, setting.TGReceivers, setting.TGNoticeText, placeholderData); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
responseResult := *fileResult
|
||||
responseResult.ID = imageModel.Id
|
||||
responseResult.FolderID = imageModel.FolderId
|
||||
services.WakeStorageSyncWorker()
|
||||
|
||||
uc.Success("URL 图片已保存到本机,正在后台同步", map[string]any{
|
||||
"file": responseResult,
|
||||
"sync_targets": len(syncBuckets),
|
||||
})
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/interfaces"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/images"
|
||||
"oneimg/backend/utils/md5"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/safefetch"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/storagepolicy"
|
||||
"oneimg/backend/utils/telegram"
|
||||
"oneimg/backend/utils/uploadpolicy"
|
||||
"oneimg/backend/utils/uploads"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -29,9 +24,37 @@ import (
|
||||
|
||||
// UploadImages 图片上传主入口
|
||||
func UploadImages(c *gin.Context) {
|
||||
release, ok := acquireUploadRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取上传配置失败"))
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, uploadpolicy.RequestBytes(setting))
|
||||
defer func() {
|
||||
if c.Request.MultipartForm != nil {
|
||||
_ = c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
uc := uploads.NewUploadContext(c)
|
||||
if c.GetInt("user_role") == models.RoleGuest && (c.GetInt("user_id") >= 0 || !validGuestUUID(CurrentOwnerKey(c))) {
|
||||
uc.Fail(403, "游客身份无效")
|
||||
return
|
||||
}
|
||||
db := database.GetDB()
|
||||
|
||||
if _, err := parseConfiguredUploadFiles(c, setting); err != nil {
|
||||
uc.Fail(uploadParseErrorStatus(err), "文件解析失败:%v", err)
|
||||
return
|
||||
}
|
||||
folderID, validFolder := multipartUploadFolder(c)
|
||||
if !validFolder {
|
||||
return
|
||||
}
|
||||
var tags []string
|
||||
var existingTags []models.Tags
|
||||
tagsStr := c.PostForm("tags")
|
||||
@@ -42,6 +65,10 @@ func UploadImages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateUploadTags(setting, tags); err != nil {
|
||||
uc.Fail(400, "%v", err)
|
||||
return
|
||||
}
|
||||
err = db.DB.Where("name IN ?", tags).Find(&existingTags).Error
|
||||
if err != nil {
|
||||
uc.Fail(500, "Tag查询失败:%v", err)
|
||||
@@ -49,14 +76,8 @@ func UploadImages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
uc.Fail(500, "获取上传配置失败:%v", err)
|
||||
return
|
||||
}
|
||||
if !setting.MultiStorageSync {
|
||||
uploadImagesLegacy(c, setting, existingTags)
|
||||
uploadImagesLegacy(c, setting, existingTags, folderID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,9 +88,9 @@ func UploadImages(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 解析并校验上传文件
|
||||
files, err := uc.ParseAndValidateFiles()
|
||||
files, err := parseConfiguredUploadFiles(c, setting)
|
||||
if err != nil {
|
||||
uc.Fail(400, "文件解析失败")
|
||||
uc.Fail(uploadParseErrorStatus(err), "文件解析失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,8 +108,8 @@ func UploadImages(c *gin.Context) {
|
||||
for _, file := range files {
|
||||
fileResult, err := uploader.Upload(c, &setting, &localBucket, file)
|
||||
if err != nil {
|
||||
uc.Fail(500, "文件[%s]保存到本机失败:%v", file.Filename, err)
|
||||
return
|
||||
uploadResults = append(uploadResults, failedUploadResult(file, "文件保存失败"))
|
||||
continue
|
||||
}
|
||||
|
||||
// 保存图片信息到数据库
|
||||
@@ -102,13 +123,19 @@ func UploadImages(c *gin.Context) {
|
||||
Height: fileResult.Height,
|
||||
Storage: fileResult.Storage,
|
||||
BucketId: localBucket.Id,
|
||||
FolderId: folderID,
|
||||
UserId: c.GetInt("user_id"),
|
||||
MD5: md5.Md5(c.GetString("username") + fileResult.FileName),
|
||||
UUID: GetUUID(c),
|
||||
UUID: CurrentOwnerKey(c),
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
resolved, err := services.ResolvePublicationFolder(tx, imageModel.UserId, imageModel.FolderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imageModel.FolderId = resolved
|
||||
if err := tx.Create(&imageModel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,16 +183,17 @@ func UploadImages(c *gin.Context) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return chargeUploadQuota(tx, localBucket, fileResult)
|
||||
})
|
||||
if err != nil {
|
||||
cleanupLocalUpload(imageModel)
|
||||
uc.Fail(500, "保存文件记录失败:%v", err)
|
||||
return
|
||||
uploadResults = append(uploadResults, failedUploadResult(file, "保存文件记录失败"))
|
||||
continue
|
||||
}
|
||||
|
||||
responseResult := *fileResult
|
||||
responseResult.ID = imageModel.Id
|
||||
responseResult.FolderID = imageModel.FolderId
|
||||
uploadResults = append(uploadResults, responseResult)
|
||||
|
||||
if setting.TGNotice {
|
||||
@@ -192,18 +220,10 @@ func UploadImages(c *gin.Context) {
|
||||
successCount++
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
uc.Fail(500, "所有文件上传失败")
|
||||
return
|
||||
if successCount > 0 {
|
||||
services.WakeStorageSyncWorker()
|
||||
}
|
||||
services.WakeStorageSyncWorker()
|
||||
|
||||
// 返回上传结果
|
||||
uc.Success("文件已保存到本机,正在后台同步", map[string]any{
|
||||
"files": uploadResults,
|
||||
"count": successCount,
|
||||
"sync_targets": len(syncBuckets),
|
||||
})
|
||||
respondUploadBatch(uc, uploadResults, "文件已保存到本机,正在后台同步", len(syncBuckets))
|
||||
}
|
||||
|
||||
// UploadImage 单文件上传
|
||||
@@ -211,291 +231,6 @@ func UploadImage(c *gin.Context) {
|
||||
UploadImages(c)
|
||||
}
|
||||
|
||||
// AddImageTag 单个添加图片标签
|
||||
func AddImageTag(c *gin.Context) {
|
||||
type TagRequest struct {
|
||||
Id int `json:"id"` // 图片ID
|
||||
Tag string `json:"tag"` // 标签ID(前端传字符串,后端转换)
|
||||
}
|
||||
|
||||
var req TagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Id <= 0 || req.Tag == "" {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
tagId, err := strconv.Atoi(req.Tag)
|
||||
if err != nil || tagId <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签ID无效"))
|
||||
return
|
||||
}
|
||||
imageId := req.Id
|
||||
|
||||
db := database.GetDB().DB
|
||||
|
||||
// 1. 查询图片是否存在
|
||||
var image models.Image
|
||||
if err := db.Where("id = ?", imageId).First(&image).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "图片不存在"))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询图片失败:"+err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 校验图片操作权限
|
||||
if !CheckImageAccessPermission(c, image, "image:tag:add") {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "无权操作"))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 查询标签是否存在
|
||||
var tag models.Tags
|
||||
if err := db.Where("id = ?", tagId).First(&tag).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签不存在"))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询标签失败:"+err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 查询图片是否已经添加过该标签
|
||||
var imageTag models.ImageToTags
|
||||
if err := db.Where("image_id = ? AND tag_id = ?", imageId, tagId).First(&imageTag).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "图片已添加过该标签"))
|
||||
return
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "检查标签关联失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 添加图片标签关联
|
||||
if err := db.Create(&models.ImageToTags{
|
||||
ImageId: imageId,
|
||||
TagId: tagId,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "添加标签失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("标签添加成功", nil))
|
||||
}
|
||||
|
||||
// DeleteImageTag 单个删除图片标签
|
||||
func DeleteImageTag(c *gin.Context) {
|
||||
type TagRequest struct {
|
||||
Id int `json:"id"` // 图片ID
|
||||
Tag int `json:"tag"` // 标签ID
|
||||
}
|
||||
|
||||
var req TagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Id <= 0 || req.Tag <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
tagId := req.Tag
|
||||
imageId := req.Id
|
||||
db := database.GetDB().DB
|
||||
|
||||
// 1. 查询图片是否存在
|
||||
var image models.Image
|
||||
if err := db.Where("id = ?", imageId).First(&image).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "图片不存在"))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询图片失败:"+err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 校验图片操作权限
|
||||
if !CheckImageAccessPermission(c, image, "image:tag:delete") {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "无权操作"))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 检查标签是否已经添加过该图片
|
||||
var imageTag models.ImageToTags
|
||||
if err := db.Where("image_id = ? AND tag_id = ?", imageId, tagId).First(&imageTag).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "关联不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 删除图片标签关联
|
||||
if err := db.Delete(&imageTag).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "删除标签失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("标签删除成功", nil))
|
||||
}
|
||||
|
||||
// DeleteImageTags 批量删除图片标签
|
||||
func DeleteImageTags(c *gin.Context) {
|
||||
type Request struct {
|
||||
Images []int `json:"image_ids"`
|
||||
Tag string `json:"tag_id"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := strconv.Atoi(req.Tag)
|
||||
if err != nil || len(req.Images) <= 0 || tagID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
|
||||
// 查询所有目标图片记录并校验权限
|
||||
var images []models.Image
|
||||
if err := db.Where("id IN ?", req.Images).Find(&images).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询图片数据失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 核对查询到的图片数量,防止前端传入不存在的图片ID
|
||||
if len(images) == 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "指定图片不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var validImageIDs []int
|
||||
for _, img := range images {
|
||||
// 校验每一张图片的操作权限
|
||||
if !CheckImageAccessPermission(c, img, "image:tag:delete") {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "无权操作部分或全部图片"))
|
||||
return
|
||||
}
|
||||
validImageIDs = append(validImageIDs, int(img.Id)) // 收集有效ID
|
||||
}
|
||||
|
||||
// 直接使用单条SQL执行批量删除,避免for循环中执行多条SQL
|
||||
if err := db.Where("image_id IN ? AND tag_id = ?", validImageIDs, tagID).Delete(&models.ImageToTags{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "批量删除标签失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("批量删除标签成功", nil))
|
||||
}
|
||||
|
||||
// AddImageTags 批量添加图片标签
|
||||
func AddImageTags(c *gin.Context) {
|
||||
type Request struct {
|
||||
Images []int `json:"image_ids"`
|
||||
Tag string `json:"tag_id"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数解析失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := strconv.Atoi(req.Tag)
|
||||
if err != nil || len(req.Images) <= 0 || tagID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
|
||||
// 1. 检查标签是否存在
|
||||
var tag models.Tags
|
||||
if err := db.Where("id = ?", tagID).First(&tag).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签不存在"))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询标签失败:"+err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 查询并校验图片列表权限
|
||||
var images []models.Image
|
||||
if err := db.Where("id IN ?", req.Images).Find(&images).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询图片列表失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var validImageIDs []int
|
||||
for _, img := range images {
|
||||
// 校验每张图的权限
|
||||
if !CheckImageAccessPermission(c, img, "image:tag:add") {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "无权操作部分或全部图片"))
|
||||
return
|
||||
}
|
||||
validImageIDs = append(validImageIDs, int(img.Id))
|
||||
}
|
||||
|
||||
if len(validImageIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "无效的图片ID"))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 筛选出已经存在关联的记录,防止重复添加
|
||||
var existRelations []int
|
||||
if err := db.Model(&models.ImageToTags{}).
|
||||
Where("image_id IN ? AND tag_id = ?", validImageIDs, tagID).
|
||||
Pluck("image_id", &existRelations).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "检查标签关联失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
existRelationMap := make(map[int]bool)
|
||||
for _, id := range existRelations {
|
||||
existRelationMap[id] = true
|
||||
}
|
||||
|
||||
// 4. 构建需要插入的新关联数据
|
||||
var insertData []models.ImageToTags
|
||||
for _, imageID := range validImageIDs {
|
||||
if existRelationMap[imageID] {
|
||||
continue // 如果已存在则跳过
|
||||
}
|
||||
insertData = append(insertData, models.ImageToTags{
|
||||
ImageId: imageID,
|
||||
TagId: tagID,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 批量插入
|
||||
if len(insertData) > 0 {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.CreateInBatches(&insertData, 100).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "批量添加标签失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, result.Success("没有需要添加的标签(可能已全部存在)", nil))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("批量添加标签成功", nil))
|
||||
}
|
||||
|
||||
// 获取上传配置
|
||||
func GetUploadConfig(c *gin.Context) {
|
||||
var tags []models.Tags
|
||||
@@ -523,32 +258,47 @@ func GetUploadConfig(c *gin.Context) {
|
||||
config := map[string]any{
|
||||
"tags": tags,
|
||||
"multi_storage_sync": setting.MultiStorageSync,
|
||||
"max_upload_files": uploadpolicy.MaxFiles(setting),
|
||||
"max_file_size": images.UploadByteLimit(int64(setting.MaxFileSize)),
|
||||
"allowed_types": effectiveUploadMIMEs(setting),
|
||||
"tag_max_length": uploadpolicy.TagLength(setting),
|
||||
"random_image_limit": uploadpolicy.RandomLimit(setting),
|
||||
}
|
||||
if !setting.MultiStorageSync {
|
||||
buckets, err := resolveLegacyUploadBuckets(c, setting)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "获取存储桶列表失败:"+err.Error()))
|
||||
internalFailure(c, "获取存储桶列表失败", err)
|
||||
return
|
||||
}
|
||||
bucketRes := make([]map[string]any, 0, len(buckets))
|
||||
effectiveDefaultBucket := setting.DefaultStorage
|
||||
effectiveDefaultBucket := defaultUploadStorageID(c.GetInt("user_role"), setting)
|
||||
defaultAvailable := false
|
||||
for _, bucket := range buckets {
|
||||
bucketRes = append(bucketRes, toResponse(bucket))
|
||||
if bucket.Id == setting.DefaultStorage {
|
||||
if bucket.Id == effectiveDefaultBucket {
|
||||
defaultAvailable = true
|
||||
}
|
||||
}
|
||||
if !defaultAvailable && len(buckets) > 0 {
|
||||
effectiveDefaultBucket = buckets[0].Id
|
||||
}
|
||||
if len(buckets) == 0 {
|
||||
policy, err := storagepolicy.Load(database.GetDB().DB)
|
||||
if err != nil {
|
||||
internalFailure(c, "获取角色存储策略失败", err)
|
||||
return
|
||||
}
|
||||
if policy.Custom(c.GetInt("user_role")) {
|
||||
effectiveDefaultBucket = 0
|
||||
}
|
||||
}
|
||||
config["buckets"] = bucketRes
|
||||
config["sync_buckets"] = []map[string]any{}
|
||||
config["default_bucket"] = effectiveDefaultBucket
|
||||
} else {
|
||||
localBucket, syncBuckets, err := resolveUploadBuckets(c, setting)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "获取同步存储源失败:"+err.Error()))
|
||||
internalFailure(c, "获取同步存储源失败", err)
|
||||
return
|
||||
}
|
||||
bucketRes := make([]map[string]any, 0, len(syncBuckets)+1)
|
||||
@@ -567,212 +317,3 @@ func GetUploadConfig(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("ok", config))
|
||||
}
|
||||
|
||||
// 通过URL上传图片
|
||||
func UploadImagesByURL(c *gin.Context) {
|
||||
uc := uploads.NewUploadContext(c)
|
||||
db := database.GetDB()
|
||||
|
||||
type URLUploadRequest struct {
|
||||
Urls string `json:"url" binding:"required"`
|
||||
Tag string `json:"tag_id"`
|
||||
BucketID string `json:"bucket_id"` // 兼容旧客户端,目标存储源以用户配置为准。
|
||||
}
|
||||
|
||||
var req URLUploadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
uc.Fail(400, "参数格式错误:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Urls == "" {
|
||||
uc.Fail(400, "URL不能为空")
|
||||
return
|
||||
}
|
||||
if req.Tag != "" && req.Tag != "0" {
|
||||
var tags models.Tags
|
||||
if err := db.DB.Where("id = ?", req.Tag).First(&tags).Error; err != nil {
|
||||
uc.Fail(400, "标签不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
uc.Fail(500, "获取上传配置失败:%v", err)
|
||||
return
|
||||
}
|
||||
if !setting.MultiStorageSync {
|
||||
uploadImageByURLLegacy(c, setting, req.Urls, req.Tag, req.BucketID)
|
||||
return
|
||||
}
|
||||
|
||||
localBucket, syncBuckets, err := resolveUploadBuckets(c, setting)
|
||||
if err != nil {
|
||||
uc.Fail(500, "获取用户同步存储源失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 下载图片(拒绝内网/metadata,防止 SSRF)
|
||||
if err := safefetch.ValidatePublicHTTPURL(req.Urls); err != nil {
|
||||
uc.Fail(400, "URL 不合法或禁止访问内网地址")
|
||||
return
|
||||
}
|
||||
resp, err := safefetch.Get(c.Request.Context(), req.Urls, 60*time.Second)
|
||||
if err != nil {
|
||||
uc.Fail(400, "图片下载失败:%v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
uc.Fail(400, "图片下载失败,远端状态码:%d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
uc.Fail(400, "URL不是图片类型")
|
||||
return
|
||||
}
|
||||
|
||||
fileName := filepath.Base(req.Urls)
|
||||
if fileName == "/" || fileName == "." || fileName == "" {
|
||||
fileName = fmt.Sprintf("url_image_%d.jpg", time.Now().Unix())
|
||||
}
|
||||
fileName = filepath.Base(strings.ReplaceAll(fileName, "\\", "/"))
|
||||
if strings.Contains(fileName, "..") || fileName == "." || fileName == "" {
|
||||
fileName = fmt.Sprintf("url_image_%d.jpg", time.Now().Unix())
|
||||
}
|
||||
|
||||
fileBytes, err := safefetch.ReadLimited(resp.Body, int64(setting.MaxFileSize))
|
||||
if err != nil {
|
||||
uc.Fail(500, "读取图片失败:%v", err)
|
||||
return
|
||||
}
|
||||
if int64(len(fileBytes)) > int64(setting.MaxFileSize) {
|
||||
uc.Fail(400, "URL 图片超过文件大小限制")
|
||||
return
|
||||
}
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
part, _ := writer.CreatePart(map[string][]string{
|
||||
"Content-Disposition": {`form-data; name="file"; filename="` + fileName + `"`},
|
||||
"Content-Type": {contentType},
|
||||
})
|
||||
part.Write(fileBytes)
|
||||
writer.Close()
|
||||
|
||||
// 伪装请求
|
||||
c.Request.Body = io.NopCloser(body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
c.Request.ContentLength = int64(body.Len())
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
uc.Fail(500, "构造文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
uploader, err := uc.GetStorageUploader(&setting, &localBucket)
|
||||
if err != nil {
|
||||
uc.Fail(500, "初始化本机存储失败:%s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fileResult, err := uploader.Upload(c, &setting, &localBucket, header)
|
||||
if err != nil {
|
||||
uc.Fail(500, "保存到本机失败[%s]:%v", fileName, err)
|
||||
return
|
||||
}
|
||||
|
||||
imageModel := models.Image{
|
||||
Url: fileResult.URL,
|
||||
Thumbnail: fileResult.ThumbnailURL,
|
||||
FileName: fileResult.FileName,
|
||||
FileSize: fileResult.FileSize,
|
||||
MimeType: fileResult.MimeType,
|
||||
Width: fileResult.Width,
|
||||
Height: fileResult.Height,
|
||||
Storage: fileResult.Storage,
|
||||
BucketId: localBucket.Id,
|
||||
UserId: c.GetInt("user_id"),
|
||||
MD5: md5.Md5(c.GetString("username") + fileResult.FileName),
|
||||
UUID: GetUUID(c),
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&imageModel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
localStatus := models.ImageStorage{
|
||||
ImageID: imageModel.Id,
|
||||
BucketID: localBucket.Id,
|
||||
Storage: localBucket.Type,
|
||||
Status: models.ImageStorageStatusSuccess,
|
||||
URL: fileResult.URL,
|
||||
Thumbnail: fileResult.ThumbnailURL,
|
||||
FileSize: fileResult.FileSize,
|
||||
ThumbnailSize: fileResult.ThumbnailSize,
|
||||
SyncedAt: &now,
|
||||
}
|
||||
if err := tx.Create(&localStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, bucket := range syncBuckets {
|
||||
storageStatus := models.ImageStorage{
|
||||
ImageID: imageModel.Id,
|
||||
BucketID: bucket.Id,
|
||||
Storage: bucket.Type,
|
||||
Status: models.ImageStorageStatusPending,
|
||||
URL: fileResult.URL,
|
||||
Thumbnail: fileResult.ThumbnailURL,
|
||||
FileSize: fileResult.FileSize,
|
||||
ThumbnailSize: fileResult.ThumbnailSize,
|
||||
}
|
||||
if err := tx.Create(&storageStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if req.Tag != "" && req.Tag != "0" {
|
||||
tagID, conversionErr := strconv.Atoi(req.Tag)
|
||||
if conversionErr != nil {
|
||||
return conversionErr
|
||||
}
|
||||
if err := tx.Create(&models.ImageToTags{ImageId: imageModel.Id, TagId: tagID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
cleanupLocalUpload(imageModel)
|
||||
uc.Fail(500, "保存文件记录失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// TG通知
|
||||
if setting.TGNotice {
|
||||
placeholderData := telegram.PlaceholderData{
|
||||
Username: c.GetString("username"),
|
||||
Date: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Filename: fileResult.FileName,
|
||||
StorageType: localBucket.Type,
|
||||
URL: buildImageResponseURL(c, setting, localBucket.Type, localBucket.Id, fileResult.URL),
|
||||
}
|
||||
if err := telegram.SendSimpleMsg(setting.TGBotToken, setting.TGReceivers, setting.TGNoticeText, placeholderData); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
responseResult := *fileResult
|
||||
responseResult.ID = imageModel.Id
|
||||
services.WakeStorageSyncWorker()
|
||||
|
||||
uc.Success("URL 图片已保存到本机,正在后台同步", map[string]any{
|
||||
"file": responseResult,
|
||||
"sync_targets": len(syncBuckets),
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/interfaces"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/images"
|
||||
"oneimg/backend/utils/md5"
|
||||
"oneimg/backend/utils/safefetch"
|
||||
"oneimg/backend/utils/telegram"
|
||||
@@ -27,7 +29,7 @@ import (
|
||||
|
||||
// uploadImagesLegacy keeps the original request-time, single-bucket upload
|
||||
// path used when multi-storage synchronization is disabled.
|
||||
func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []models.Tags) {
|
||||
func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []models.Tags, folderID int) {
|
||||
uc := uploads.NewUploadContext(c)
|
||||
db := database.GetDB()
|
||||
|
||||
@@ -57,9 +59,9 @@ func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []
|
||||
return
|
||||
}
|
||||
|
||||
files, err := uc.ParseAndValidateFiles()
|
||||
files, err := parseConfiguredUploadFiles(c, setting)
|
||||
if err != nil {
|
||||
uc.Fail(http.StatusBadRequest, "文件解析失败:%v", err)
|
||||
uc.Fail(uploadParseErrorStatus(err), "文件解析失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []
|
||||
for _, file := range files {
|
||||
uploadSize += uint64(file.Size)
|
||||
}
|
||||
if bucket.Usage+uploadSize >= bucket.Capacity {
|
||||
if !bucket.CanStore(uploadSize) {
|
||||
uc.Fail(http.StatusBadRequest, "存储空间已满, 请切换存储")
|
||||
return
|
||||
}
|
||||
@@ -84,8 +86,8 @@ func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []
|
||||
for _, file := range files {
|
||||
fileResult, uploadErr := uploader.Upload(c, &setting, &bucket, file)
|
||||
if uploadErr != nil {
|
||||
uc.Fail(http.StatusInternalServerError, "文件[%s]上传失败:%v", file.Filename, uploadErr)
|
||||
return
|
||||
results = append(results, failedUploadResult(file, "文件上传失败"))
|
||||
continue
|
||||
}
|
||||
|
||||
imageModel := models.Image{
|
||||
@@ -98,13 +100,19 @@ func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []
|
||||
Height: fileResult.Height,
|
||||
Storage: fileResult.Storage,
|
||||
BucketId: bucketID,
|
||||
FolderId: folderID,
|
||||
UserId: c.GetInt("user_id"),
|
||||
MD5: md5.Md5(c.GetString("username") + fileResult.FileName),
|
||||
UUID: GetUUID(c),
|
||||
UUID: CurrentOwnerKey(c),
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
resolved, err := services.ResolvePublicationFolder(tx, imageModel.UserId, imageModel.FolderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imageModel.FolderId = resolved
|
||||
if err := tx.Create(&imageModel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -131,25 +139,16 @@ func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return chargeUploadQuota(tx, bucket, fileResult)
|
||||
}); err != nil {
|
||||
uc.Fail(http.StatusInternalServerError, "保存文件记录失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if fileResult.Storage != "default" {
|
||||
totalSize := uint64(fileResult.FileSize + fileResult.ThumbnailSize)
|
||||
usageColumn := database.UsageColumn(db.DB)
|
||||
update := db.DB.Model(&models.Buckets{}).
|
||||
Where("id = ? AND ("+usageColumn+" + ? <= capacity OR type IN ('telegram','default') OR capacity = 0)", bucketID, totalSize).
|
||||
UpdateColumn("usage", gorm.Expr(usageColumn+" + ?", totalSize))
|
||||
if update.Error != nil {
|
||||
log.Printf("更新Usage失败:%v", update.Error)
|
||||
}
|
||||
cleanupUnpublishedUpload(c, imageModel, fileResult)
|
||||
results = append(results, failedUploadResult(file, "保存记录失败或容量不足"))
|
||||
continue
|
||||
}
|
||||
|
||||
responseResult := *fileResult
|
||||
responseResult.ID = imageModel.Id
|
||||
responseResult.FolderID = imageModel.FolderId
|
||||
responseResult.URL = applyPublicImageURL(setting, bucket.Type, bucketID, fileResult.URL)
|
||||
responseResult.ThumbnailURL = applyPublicImageURL(setting, bucket.Type, bucketID, fileResult.ThumbnailURL)
|
||||
results = append(results, responseResult)
|
||||
@@ -168,13 +167,10 @@ func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []
|
||||
}
|
||||
}
|
||||
|
||||
uc.Success("上传成功", map[string]any{
|
||||
"files": results,
|
||||
"count": len(results),
|
||||
})
|
||||
respondUploadBatch(uc, results, "上传成功", 0)
|
||||
}
|
||||
|
||||
func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag, rawBucketID string) {
|
||||
func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag, rawBucketID string, folderID int) {
|
||||
uc := uploads.NewUploadContext(c)
|
||||
db := database.GetDB()
|
||||
bucketID, err := resolveLegacyRequestedBucketID(c, setting, rawBucketID)
|
||||
@@ -203,7 +199,7 @@ func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag
|
||||
uc.Fail(http.StatusBadRequest, "URL 不合法或禁止访问内网地址")
|
||||
return
|
||||
}
|
||||
resp, err := safefetch.Get(c.Request.Context(), rawURL, 60*time.Second)
|
||||
resp, err := fetchUploadURL(c.Request.Context(), rawURL, 60*time.Second)
|
||||
if err != nil {
|
||||
uc.Fail(http.StatusBadRequest, "图片下载失败:%v", err)
|
||||
return
|
||||
@@ -214,11 +210,6 @@ func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag
|
||||
return
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
uc.Fail(http.StatusBadRequest, "URL不是图片类型")
|
||||
return
|
||||
}
|
||||
fileName := filepath.Base(rawURL)
|
||||
if fileName == "/" || fileName == "." || fileName == "" {
|
||||
fileName = fmt.Sprintf("url_image_%d.jpg", time.Now().Unix())
|
||||
@@ -227,15 +218,21 @@ func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag
|
||||
if strings.Contains(fileName, "..") || fileName == "." || fileName == "" {
|
||||
fileName = fmt.Sprintf("url_image_%d.jpg", time.Now().Unix())
|
||||
}
|
||||
fileBytes, err := safefetch.ReadLimited(resp.Body, int64(setting.MaxFileSize))
|
||||
maxBytes := int64(setting.MaxFileSize)
|
||||
if maxBytes <= 0 || maxBytes > images.MaxUploadBytes {
|
||||
maxBytes = images.MaxUploadBytes
|
||||
}
|
||||
fileBytes, err := safefetch.ReadLimited(resp.Body, maxBytes)
|
||||
if err != nil {
|
||||
uc.Fail(http.StatusInternalServerError, "读取图片失败:%v", err)
|
||||
uc.Fail(http.StatusBadRequest, "URL 图片超过限制或下载失败")
|
||||
return
|
||||
}
|
||||
if len(fileBytes) > setting.MaxFileSize {
|
||||
uc.Fail(http.StatusBadRequest, "URL 图片超过文件大小限制")
|
||||
info, err := inspectDownloadedImage(fileBytes)
|
||||
if err != nil || !images.AllowedMIME(info.MIME, strings.Split(setting.AllowedTypes, ",")) {
|
||||
uc.Fail(http.StatusBadRequest, "URL不是允许的安全图片")
|
||||
return
|
||||
}
|
||||
contentType := info.MIME
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
@@ -291,50 +288,17 @@ func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag
|
||||
Height: fileResult.Height,
|
||||
Storage: fileResult.Storage,
|
||||
BucketId: bucketID,
|
||||
FolderId: folderID,
|
||||
UserId: c.GetInt("user_id"),
|
||||
MD5: md5.Md5(c.GetString("username") + fileResult.FileName),
|
||||
UUID: GetUUID(c),
|
||||
UUID: CurrentOwnerKey(c),
|
||||
}
|
||||
now := time.Now()
|
||||
if err := db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&imageModel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
storageStatus := models.ImageStorage{
|
||||
ImageID: imageModel.Id,
|
||||
BucketID: bucketID,
|
||||
Storage: bucket.Type,
|
||||
Status: models.ImageStorageStatusSuccess,
|
||||
URL: fileResult.URL,
|
||||
Thumbnail: fileResult.ThumbnailURL,
|
||||
FileSize: fileResult.FileSize,
|
||||
ThumbnailSize: fileResult.ThumbnailSize,
|
||||
SyncedAt: &now,
|
||||
}
|
||||
if err := tx.Create(&storageStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if tag != "" && tag != "0" {
|
||||
tagID, err := strconv.Atoi(tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.ImageToTags{ImageId: imageModel.Id, TagId: tagID}).Error
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
uc.Fail(http.StatusInternalServerError, "保存文件记录失败:%v", err)
|
||||
if err := saveLegacyURLUpload(db.DB, bucket, &imageModel, fileResult, tag); err != nil {
|
||||
cleanupUnpublishedUpload(c, imageModel, fileResult)
|
||||
uc.Fail(http.StatusInternalServerError, "保存文件记录失败或容量不足")
|
||||
return
|
||||
}
|
||||
|
||||
if fileResult.Storage != "default" {
|
||||
totalSize := uint64(fileResult.FileSize + fileResult.ThumbnailSize)
|
||||
usageColumn := database.UsageColumn(db.DB)
|
||||
db.DB.Model(&models.Buckets{}).
|
||||
Where("id = ? AND ("+usageColumn+" + ? <= capacity OR type IN ('telegram','default') OR capacity = 0)", bucketID, totalSize).
|
||||
UpdateColumn("usage", gorm.Expr(usageColumn+" + ?", totalSize))
|
||||
}
|
||||
|
||||
if setting.TGNotice {
|
||||
data := telegram.PlaceholderData{
|
||||
Username: c.GetString("username"),
|
||||
@@ -350,11 +314,52 @@ func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag
|
||||
|
||||
responseResult := *fileResult
|
||||
responseResult.ID = imageModel.Id
|
||||
responseResult.FolderID = imageModel.FolderId
|
||||
responseResult.URL = applyPublicImageURL(setting, bucket.Type, bucketID, fileResult.URL)
|
||||
responseResult.ThumbnailURL = applyPublicImageURL(setting, bucket.Type, bucketID, fileResult.ThumbnailURL)
|
||||
uc.Success("URL 图片上传成功", map[string]any{"file": responseResult})
|
||||
}
|
||||
|
||||
// Publish URL uploads and account the processed main/thumbnail bytes atomically,
|
||||
// just like multipart uploads. Tags must not bypass the final quota check.
|
||||
func saveLegacyURLUpload(db *gorm.DB, bucket models.Buckets, image *models.Image, result *interfaces.ImageUploadResult, tag string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
resolved, err := services.ResolvePublicationFolder(tx, image.UserId, image.FolderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
image.FolderId = resolved
|
||||
if err := tx.Create(image).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
storageStatus := models.ImageStorage{
|
||||
ImageID: image.Id,
|
||||
BucketID: bucket.Id,
|
||||
Storage: bucket.Type,
|
||||
Status: models.ImageStorageStatusSuccess,
|
||||
URL: result.URL,
|
||||
Thumbnail: result.ThumbnailURL,
|
||||
FileSize: result.FileSize,
|
||||
ThumbnailSize: result.ThumbnailSize,
|
||||
SyncedAt: &now,
|
||||
}
|
||||
if err := tx.Create(&storageStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if tag != "" && tag != "0" {
|
||||
tagID, err := strconv.Atoi(tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&models.ImageToTags{ImageId: image.Id, TagId: tagID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return chargeUploadQuota(tx, bucket, result)
|
||||
})
|
||||
}
|
||||
|
||||
func resolveLegacyRequestedBucketID(c *gin.Context, setting models.Settings, rawBucketID string) (int, error) {
|
||||
if rawBucketID != "" {
|
||||
bucketID, err := strconv.Atoi(rawBucketID)
|
||||
@@ -369,7 +374,7 @@ func resolveLegacyRequestedBucketID(c *gin.Context, setting models.Settings, raw
|
||||
return 0, fmt.Errorf("获取可用存储源失败:%w", err)
|
||||
}
|
||||
for _, bucket := range available {
|
||||
if bucket.Id == setting.DefaultStorage {
|
||||
if bucket.Id == defaultUploadStorageID(c.GetInt("user_role"), setting) {
|
||||
return bucket.Id, nil
|
||||
}
|
||||
}
|
||||
|
||||
+79
-135
@@ -1,12 +1,15 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"oneimg/backend/utils/powverify"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/uploadpolicy"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,12 +20,13 @@ import (
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoginRequest 登录请求体。
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Username string `json:"username" binding:"required,max=64"`
|
||||
Password string `json:"password" binding:"required,max=72"`
|
||||
PowToken string `json:"powToken"`
|
||||
TurnstileToken string `json:"turnstileToken"`
|
||||
CapToken string `json:"capToken"`
|
||||
@@ -31,28 +35,29 @@ type LoginRequest struct {
|
||||
StableFeatures map[string]any `json:"stableFeatures"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应体(兼容旧字段)。
|
||||
// LoginResponse contains no session bearer material.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
User *models.User `json:"user,omitempty"`
|
||||
User *models.User `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// Login 处理账号密码登录;开启游客时支持指纹/UUID 游客会话。
|
||||
// Login uses cookie sessions; guest fingerprints are compatibility markers, never credentials.
|
||||
func Login(c *gin.Context) {
|
||||
db := database.GetDB()
|
||||
if db == nil || db.DB == nil {
|
||||
c.JSON(503, result.Error(503, "数据库连接失败"))
|
||||
return
|
||||
}
|
||||
loginDB(c, db.DB)
|
||||
}
|
||||
func loginDB(c *gin.Context, db *gorm.DB) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "数据库连接失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var settings models.Settings
|
||||
sqlResult := db.DB.First(&settings)
|
||||
sqlResult := db.Where("id = ?", 1).First(&settings)
|
||||
if sqlResult.Error != nil {
|
||||
if strings.Contains(sqlResult.Error.Error(), "record not found") {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "系统配置未初始化"))
|
||||
@@ -64,109 +69,76 @@ func Login(c *gin.Context) {
|
||||
|
||||
if ok, errMsg, fallback := verifyHuman(c, settings, req.PowToken, req.TurnstileToken, req.CapToken); !ok {
|
||||
if fallback != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"code": 403,
|
||||
"message": errMsg,
|
||||
"data": gin.H{"verify_fallback": fallback},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, errMsg))
|
||||
c.JSON(http.StatusForbidden, result.Error(403, errMsg))
|
||||
return
|
||||
}
|
||||
|
||||
// 游客:指纹 UUID / guest_ 前缀 / 固定 guest
|
||||
if settings.Tourist {
|
||||
isTourist := len(req.TouristFingerprint) == 36 ||
|
||||
strings.HasPrefix(req.Username, "guest_") ||
|
||||
req.Username == "guest"
|
||||
|
||||
if isTourist {
|
||||
touristUUID := req.TouristFingerprint
|
||||
if touristUUID == "" {
|
||||
touristUUID = req.Username
|
||||
if touristUUID == "guest" {
|
||||
touristUUID = generateRandomUUID()
|
||||
}
|
||||
}
|
||||
|
||||
touristID := int(generateTouristID(touristUUID))
|
||||
touristUser := &models.User{
|
||||
ID: touristID,
|
||||
Role: models.RoleGuest,
|
||||
Username: touristUUID,
|
||||
}
|
||||
|
||||
session, err := SetSession(c, touristUser)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "游客登录失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("游客登录成功", map[string]any{
|
||||
"token": session.ID(),
|
||||
"user": &models.User{
|
||||
ID: touristUser.ID,
|
||||
Role: models.RoleGuest,
|
||||
Username: touristUser.Username,
|
||||
},
|
||||
}))
|
||||
// A guest request may retain the old frontend's marker fields, but none of
|
||||
// those fields are accepted as proof of identity or as an ownership key.
|
||||
isGuestRequest := req.Username == "guest" || strings.HasPrefix(req.Username, "guest_") || req.TouristFingerprint != ""
|
||||
if isGuestRequest {
|
||||
if !settings.Tourist {
|
||||
c.JSON(403, result.Error(403, "游客模式未开启"))
|
||||
return
|
||||
}
|
||||
credential, _ := c.Cookie(authsecurity.GuestCookie)
|
||||
guest, raw, recovered, err := authsecurity.RecoverGuest(db, credential)
|
||||
if err != nil || guest.ID <= 0 || guest.ID > 2147483647 {
|
||||
c.JSON(500, result.Error(500, "游客身份创建失败"))
|
||||
return
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{Name: authsecurity.GuestCookie, Value: raw, Path: "/", HttpOnly: true,
|
||||
Secure: config.App != nil && strings.HasPrefix(strings.ToLower(config.App.AppURL), "https://"),
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: int(authsecurity.GuestLifetime.Seconds()), Expires: time.Now().Add(authsecurity.GuestLifetime)})
|
||||
guestUser := &models.User{ID: -guest.ID, Role: models.RoleGuest, Username: guest.OwnerKey}
|
||||
if _, err := SetSession(c, guestUser); err != nil {
|
||||
return
|
||||
}
|
||||
c.JSON(200, result.Success("游客登录成功", gin.H{"user": guestUser, "guest_recovered": recovered,
|
||||
"guest_notice": "游客身份由本浏览器的安全 Cookie 保存;清除此 Cookie 将无法恢复历史图片。旧指纹不能用于认领图片。"}))
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
userInfo := db.DB.Where("username = ?", req.Username).First(&user)
|
||||
userInfo := db.Where("username = ?", req.Username).First(&user)
|
||||
if userInfo.Error != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(401, "用户名或密码错误"))
|
||||
c.JSON(http.StatusUnauthorized, result.Error(401, "用户名或密码错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(401, "用户名或密码错误"))
|
||||
c.JSON(http.StatusUnauthorized, result.Error(401, "用户名或密码错误"))
|
||||
return
|
||||
}
|
||||
|
||||
session, err := SetSession(c, &user)
|
||||
_, err := SetSession(c, &user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
recordAccountLogin(c, db, user.ID, authsecurity.LoginMethodPassword)
|
||||
|
||||
user.Password = ""
|
||||
c.JSON(http.StatusOK, result.Success("登录成功", map[string]any{
|
||||
"token": session.ID(),
|
||||
"user": user,
|
||||
"user": user,
|
||||
}))
|
||||
}
|
||||
|
||||
// generateTouristID 由 UUID 派生稳定游客数字 ID(避开 1 号超管)。
|
||||
func generateTouristID(uuid string) uint {
|
||||
var id uint = 2
|
||||
for _, c := range uuid {
|
||||
id = id*31 + uint(c)
|
||||
}
|
||||
if id <= 2 {
|
||||
id += 100000
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// generateRandomUUID 生成 UUID v4。
|
||||
func generateRandomUUID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "guest_" + time.Now().Format("20060102150405.000000000")
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// SetSession 写入用户会话并返回 session 对象。
|
||||
func SetSession(c *gin.Context, user *models.User) (sessions.Session, error) {
|
||||
session, err := saveUserSession(c, user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "session保存失败:"+err.Error()))
|
||||
if errors.Is(err, middlewares.ErrSessionRevoked) {
|
||||
c.JSON(401, result.Error(401, "会话已撤销,请重新登录"))
|
||||
return nil, err
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "会话创建失败,请稍后重试"))
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
@@ -175,6 +147,15 @@ func SetSession(c *gin.Context, user *models.User) (sessions.Session, error) {
|
||||
// saveUserSession 仅保存会话,由调用方决定 JSON 或重定向响应。
|
||||
func saveUserSession(c *gin.Context, user *models.User) (sessions.Session, error) {
|
||||
session := sessions.Default(c)
|
||||
epoch := session.Get("_epoch")
|
||||
reissue := session.Get("_reissue")
|
||||
session.Clear()
|
||||
if reissue == true {
|
||||
session.Set("_reissue", true)
|
||||
}
|
||||
session.Set("_epoch", epoch)
|
||||
session.Set("_rotate", true)
|
||||
session.Set("auth_version", user.AuthVersion)
|
||||
session.Set("user_id", user.ID)
|
||||
session.Set("user_role", user.Role)
|
||||
session.Set("username", user.Username)
|
||||
@@ -182,8 +163,8 @@ func saveUserSession(c *gin.Context, user *models.User) (sessions.Session, error
|
||||
session.Options(sessions.Options{
|
||||
MaxAge: 24 * 60 * 60,
|
||||
HttpOnly: true,
|
||||
Secure: strings.HasPrefix(strings.ToLower(config.App.AppURL), "https://"),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: config.App != nil && strings.HasPrefix(strings.ToLower(config.App.AppURL), "https://"),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
if err := session.Save(); err != nil {
|
||||
@@ -192,63 +173,26 @@ func saveUserSession(c *gin.Context, user *models.User) (sessions.Session, error
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// ValidatePowToken 向 PoW 服务校验 token。
|
||||
// ValidatePowToken retains the legacy Go contract; HTTP request paths pass
|
||||
// their cancellation context and the already-loaded settings instead.
|
||||
func ValidatePowToken(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
type reqBody struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
body, err := json.Marshal(reqBody{Token: token})
|
||||
s, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://cha.eta.im/api/validate", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return ValidatePowTokenWithSettings(context.Background(), s, token)
|
||||
}
|
||||
func ValidatePowTokenWithSettings(ctx context.Context, s models.Settings, token string) bool {
|
||||
if s.PowLocalFallback {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var validationResp struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &validationResp); err != nil {
|
||||
return false
|
||||
}
|
||||
return validationResp.Success
|
||||
return powverify.Validate(ctx, uploadpolicy.PowURL(s), token, time.Duration(uploadpolicy.PowTimeoutSeconds(s))*time.Second)
|
||||
}
|
||||
|
||||
// Logout 清除当前会话。
|
||||
func Logout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "退出登录失败"))
|
||||
if err := middlewares.DeleteCurrentSession(c); err != nil {
|
||||
c.JSON(500, result.Error(500, "退出登录失败"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("退出登录成功", nil))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"oneimg/backend/models"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func effectivePowScriptURL(s models.Settings) string {
|
||||
if strings.TrimSpace(s.PowScriptURL) == "" {
|
||||
return "https://cha.eta.im/static/js/pow.min.js"
|
||||
}
|
||||
return strings.TrimSpace(s.PowScriptURL)
|
||||
}
|
||||
func effectivePowWidgetURL(s models.Settings) string {
|
||||
if strings.TrimSpace(s.PowWidgetURL) == "" {
|
||||
return "https://cha.eta.im/"
|
||||
}
|
||||
return strings.TrimSpace(s.PowWidgetURL)
|
||||
}
|
||||
+38
-614
@@ -1,154 +1,20 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/storage"
|
||||
"oneimg/backend/utils/buckets"
|
||||
"oneimg/backend/utils/ftp"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/s3"
|
||||
"oneimg/backend/utils/securestorage"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/telegram"
|
||||
"oneimg/backend/utils/watermark"
|
||||
"oneimg/backend/utils/webdav"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type resolvedImageAccess struct {
|
||||
bucket models.Buckets
|
||||
replica *models.ImageStorage
|
||||
storageType string
|
||||
path string
|
||||
}
|
||||
|
||||
func resolveImageAccess(db *gorm.DB, image models.Image, thumbnail bool) (resolvedImageAccess, error) {
|
||||
canonicalPath := image.Url
|
||||
if thumbnail {
|
||||
canonicalPath = image.Thumbnail
|
||||
}
|
||||
|
||||
// An explicitly selected source must have both an enabled bucket and a
|
||||
// successful replica. If it cannot currently serve the requested object,
|
||||
// transparently fall back to the durable local copy.
|
||||
if image.AccessBucketId > 0 {
|
||||
if resolved, ok := resolveImageReplicaAccess(db, image.Id, image.AccessBucketId, thumbnail); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
if resolved, ok := resolveLocalImageAccess(db, image.Id, thumbnail); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
} else if image.Storage != "default" {
|
||||
// Zero means the default access policy. Prefer a successful local
|
||||
// replica whenever one exists, including migrated legacy images whose
|
||||
// canonical record still points at a remote bucket.
|
||||
if resolved, ok := resolveLocalImageAccess(db, image.Id, thumbnail); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
|
||||
var canonicalBucket models.Buckets
|
||||
canonicalErr := db.First(&canonicalBucket, image.BucketId).Error
|
||||
if canonicalErr == nil && !canonicalBucket.Disabled && canonicalPath != "" {
|
||||
storageType := image.Storage
|
||||
if storageType == "" {
|
||||
storageType = canonicalBucket.Type
|
||||
}
|
||||
resolved := resolvedImageAccess{
|
||||
bucket: canonicalBucket,
|
||||
storageType: storageType,
|
||||
path: canonicalPath,
|
||||
}
|
||||
var replica models.ImageStorage
|
||||
if err := db.Where(
|
||||
"image_id = ? AND bucket_id = ? AND status = ?",
|
||||
image.Id, canonicalBucket.Id, models.ImageStorageStatusSuccess,
|
||||
).First(&replica).Error; err == nil {
|
||||
resolved.replica = &replica
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
if resolved, ok := resolveLocalImageAccess(db, image.Id, thumbnail); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
if canonicalErr != nil && !errors.Is(canonicalErr, gorm.ErrRecordNotFound) {
|
||||
return resolvedImageAccess{}, canonicalErr
|
||||
}
|
||||
return resolvedImageAccess{}, errors.New("没有可用的图片存储源")
|
||||
}
|
||||
|
||||
func resolveImageReplicaAccess(db *gorm.DB, imageID, bucketID int, thumbnail bool) (resolvedImageAccess, bool) {
|
||||
var replica models.ImageStorage
|
||||
if err := db.Where(
|
||||
"image_id = ? AND bucket_id = ? AND status = ?",
|
||||
imageID, bucketID, models.ImageStorageStatusSuccess,
|
||||
).First(&replica).Error; err != nil {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
|
||||
var bucket models.Buckets
|
||||
if err := db.Where("id = ? AND disabled = ?", bucketID, false).First(&bucket).Error; err != nil {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
path := replica.URL
|
||||
if thumbnail {
|
||||
path = replica.Thumbnail
|
||||
}
|
||||
if path == "" {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
storageType := replica.Storage
|
||||
if storageType == "" {
|
||||
storageType = bucket.Type
|
||||
}
|
||||
return resolvedImageAccess{bucket: bucket, replica: &replica, storageType: storageType, path: path}, true
|
||||
}
|
||||
|
||||
func resolveLocalImageAccess(db *gorm.DB, imageID int, thumbnail bool) (resolvedImageAccess, bool) {
|
||||
var replica models.ImageStorage
|
||||
if err := db.Model(&models.ImageStorage{}).
|
||||
Select("image_storages.*").
|
||||
Joins("JOIN buckets ON buckets.id = image_storages.bucket_id").
|
||||
Where(
|
||||
"image_storages.image_id = ? AND image_storages.status = ? AND buckets.type = ? AND buckets.disabled = ?",
|
||||
imageID, models.ImageStorageStatusSuccess, "default", false,
|
||||
).
|
||||
Order("buckets.id ASC").
|
||||
First(&replica).Error; err != nil {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
|
||||
var bucket models.Buckets
|
||||
if err := db.First(&bucket, replica.BucketID).Error; err != nil {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
path := replica.URL
|
||||
if thumbnail {
|
||||
path = replica.Thumbnail
|
||||
}
|
||||
if path == "" {
|
||||
return resolvedImageAccess{}, false
|
||||
}
|
||||
return resolvedImageAccess{bucket: bucket, replica: &replica, storageType: "default", path: path}, true
|
||||
}
|
||||
|
||||
func ImageProxy(c *gin.Context) bool {
|
||||
// 获取并清理路径
|
||||
cleanPath := c.Request.URL.Path
|
||||
@@ -174,6 +40,10 @@ func ImageProxy(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if imageModel.Deleting {
|
||||
c.AbortWithStatus(http.StatusGone)
|
||||
return true
|
||||
}
|
||||
// 获取配置信息
|
||||
setting, setErr := settings.GetSettings()
|
||||
if setErr != nil {
|
||||
@@ -182,9 +52,9 @@ func ImageProxy(c *gin.Context) bool {
|
||||
}
|
||||
|
||||
// 检查是否开启来源白名单
|
||||
if setting.RefererWhiteEnable && setting.RefererWhiteList != "" {
|
||||
if setting.RefererWhiteEnable {
|
||||
// 校验Referer白名单
|
||||
if !checkReferer(c.Request.Referer(), setting.RefererWhiteList, GetSelfDomain(c)) {
|
||||
if !checkRefererPolicy(c.Request.Referer(), setting.RefererWhiteList, GetSelfDomain(c), setting.RefererAllowEmpty) {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "来源非法"))
|
||||
return true
|
||||
}
|
||||
@@ -205,488 +75,42 @@ func ImageProxy(c *gin.Context) bool {
|
||||
bucket := access.bucket
|
||||
imageUrl := access.path
|
||||
|
||||
// 传递水印配置到各个代理函数
|
||||
switch access.storageType {
|
||||
case "default":
|
||||
proxyLocalFile(c, imageUrl, imageModel.MimeType, watermarkCfg)
|
||||
|
||||
case "webdav":
|
||||
proxyWebDAVFile(c, imageUrl, imageModel.MimeType, bucket, watermarkCfg)
|
||||
case "r2":
|
||||
// 初始化S3兼容客户端
|
||||
s3Client, err := s3.NewS3Client(setting, bucket)
|
||||
// R2 signing remains a separate policy-aware capability. Raw proxied bytes
|
||||
// (encrypted objects/watermark/historical SVG) use the same driver as uploads.
|
||||
var driver storage.Driver
|
||||
if access.storageType == "r2" {
|
||||
client, err := s3.NewS3Client(setting, bucket)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, fmt.Sprintf("R2客户端初始化失败: %v", err)))
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "R2客户端初始化失败"))
|
||||
return true
|
||||
}
|
||||
// 提取 R2 Bucket 名称
|
||||
storageConfig := buckets.ConvertToR2Bucket(bucket.Config)
|
||||
proxyObjectStorageFile(c, imageUrl, imageModel.MimeType, storageConfig.R2Bucket, bucket, s3Client, watermarkCfg)
|
||||
|
||||
case "s3":
|
||||
// 初始化S3兼容客户端
|
||||
s3Client, err := s3.NewS3Client(setting, bucket)
|
||||
cfg := buckets.ConvertToR2Bucket(bucket.Config)
|
||||
if !isHistoricalSVG(imageModel, imageUrl) && redirectR2Object(c, imageUrl, cfg.R2Bucket, bucket, client, watermarkCfg) {
|
||||
return true
|
||||
}
|
||||
driver, err = storage.NewS3(client, cfg.R2Bucket)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, fmt.Sprintf("S3客户端初始化失败: %v", err)))
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "存储配置缺失"))
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
driver, err = storage.New(setting, bucket)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "存储客户端初始化失败"))
|
||||
return true
|
||||
}
|
||||
// 提取 S3 Bucket 名称
|
||||
storageConfig := buckets.ConvertToS3Bucket(bucket.Config)
|
||||
proxyObjectStorageFile(c, imageUrl, imageModel.MimeType, storageConfig.S3Bucket, bucket, s3Client, watermarkCfg)
|
||||
|
||||
case "ftp":
|
||||
proxyFTPFile(c, imageUrl, imageModel.MimeType, bucket, watermarkCfg)
|
||||
|
||||
case "telegram":
|
||||
ProxyTelegramFile(c, imageUrl, imageModel.FileName, imageModel.MimeType, bucket, access.replica, watermarkCfg)
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusUnprocessableEntity, result.Error(422, fmt.Sprintf("不支持的存储类型: %s", access.storageType)))
|
||||
}
|
||||
|
||||
var metadata map[string]any
|
||||
if access.replica != nil {
|
||||
metadata = access.replica.Metadata
|
||||
}
|
||||
if access.storageType == "telegram" {
|
||||
metadata, err = storage.ResolveTelegramMetadata(db.DB, imageModel.FileName, metadata)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "查询文件元数据失败"))
|
||||
return true
|
||||
}
|
||||
}
|
||||
proxyStorageFile(c, driver, storage.ArtifactRef(imageUrl, metadata, imageModel.Thumbnail == cleanPath), imageModel.MimeType, access.storageType, watermarkCfg)
|
||||
return true
|
||||
}
|
||||
|
||||
// serveStoredImage is the single plaintext boundary for every storage
|
||||
// backend. Storage objects may be legacy plaintext or versioned ciphertext;
|
||||
// browsers always receive the decoded image bytes.
|
||||
func serveStoredImage(c *gin.Context, stored io.Reader, mimeType, storageType string, watermarkCfg watermark.WatermarkConfig) error {
|
||||
content, _, err := securestorage.ReadAll(stored)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("X-Storage-Type", storageType)
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
if watermarkCfg.Enable {
|
||||
processedReader, watermarkErr := watermark.ProcessImageWithWatermark(bytes.NewReader(content), mimeType, watermarkCfg)
|
||||
if watermarkErr == nil {
|
||||
c.Writer.Header().Del("Content-Length")
|
||||
c.Header("Transfer-Encoding", "chunked")
|
||||
c.Status(http.StatusOK)
|
||||
_, err = io.Copy(c.Writer, processedReader)
|
||||
return err
|
||||
}
|
||||
log.Printf("处理%s文件水印失败,返回解密后的原图: %v", storageType, watermarkErr)
|
||||
}
|
||||
|
||||
// ServeContent keeps HEAD and Range requests working even though encrypted
|
||||
// objects have to be authenticated before any plaintext can be returned.
|
||||
c.Writer.Header().Del("Transfer-Encoding")
|
||||
http.ServeContent(
|
||||
c.Writer,
|
||||
c.Request,
|
||||
filepath.Base(c.Request.URL.Path),
|
||||
time.Time{},
|
||||
bytes.NewReader(content),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// proxyObjectStorageFile 代理对象存储文件
|
||||
func proxyObjectStorageFile(c *gin.Context, objectKey, mimeType, bucketName string, bucket models.Buckets, s3Client *minio.Client, watermarkCfg watermark.WatermarkConfig) {
|
||||
objectKey = strings.TrimPrefix(objectKey, "/")
|
||||
|
||||
// 校验bucket和objectKey
|
||||
if bucketName == "" || objectKey == "" {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "存储配置缺失"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
obj, err := s3Client.GetObject(ctx, bucketName, objectKey, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
var minioErr minio.ErrorResponse
|
||||
if errors.As(err, &minioErr) {
|
||||
switch minioErr.Code {
|
||||
case "NoSuchKey":
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "文件不存在"))
|
||||
return
|
||||
case "AccessDenied":
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "文件访问权限不足"))
|
||||
return
|
||||
}
|
||||
|
||||
switch minioErr.StatusCode {
|
||||
case http.StatusForbidden:
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "文件访问权限不足"))
|
||||
return
|
||||
case http.StatusRequestTimeout:
|
||||
c.JSON(http.StatusGatewayTimeout, result.Error(504, "存储请求超时"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[%s]获取文件失败 [key:%s, bucket:%s]: %v", bucket.Type, objectKey, bucket.Name, err)
|
||||
c.JSON(http.StatusBadGateway, result.Error(502, "文件获取失败"))
|
||||
return
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
if err := serveStoredImage(c, obj, mimeType, bucket.Type, watermarkCfg); err != nil {
|
||||
log.Printf("[%s]文件解密或传输失败 [key:%s]: %v", bucket.Type, objectKey, err)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "文件解密失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxyWebDAVFile WebDAV文件代理(添加水印支持)
|
||||
func proxyWebDAVFile(c *gin.Context, relPath, mimeType string, bucket models.Buckets, watermarkCfg watermark.WatermarkConfig) {
|
||||
// 获取存储配置
|
||||
storageConfig := buckets.ConvertToWebDavBucket(bucket.Config)
|
||||
|
||||
// 初始化WebDav客户端
|
||||
if storageConfig.WebdavURL == "" {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "WebDAV配置未设置(WebdavURL为空)"))
|
||||
return
|
||||
}
|
||||
client := webdav.Client(webdav.Config{
|
||||
BaseURL: storageConfig.WebdavURL,
|
||||
Username: storageConfig.WebdavUser,
|
||||
Password: storageConfig.WebdavPass,
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
// 验证WebDAV连接(非阻塞,仅日志)
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if _, err := client.WebDAVStat(ctx, ""); err != nil {
|
||||
log.Printf("WebDAV连接验证失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 验证文件存在
|
||||
exists, err := client.WebDAVStat(ctx, relPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "WebDAV文件状态验证失败"))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "WebDAV文件不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件流
|
||||
resp, err := client.WebDAVGetFile(ctx, relPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, result.Error(502, "WebDAV文件获取失败"))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 校验响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(resp.StatusCode, result.Error(resp.StatusCode, "WebDAV文件获取失败"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := serveStoredImage(c, resp.Body, mimeType, bucket.Type, watermarkCfg); err != nil {
|
||||
log.Printf("WebDAV文件解密或传输失败:%v", err)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "WebDAV文件解密失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxyLocalFile 本地文件代理(添加水印支持)
|
||||
func proxyLocalFile(c *gin.Context, realPath string, mimeType string, watermarkCfg watermark.WatermarkConfig) {
|
||||
fullPath, err := resolveSafeLocalPath(realPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "文件路径非法"))
|
||||
return
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(fullPath)
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "文件不存在"))
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "文件状态验证失败"))
|
||||
return
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "文件不可访问"))
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "打开文件失败"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := serveStoredImage(c, file, mimeType, "default", watermarkCfg); err != nil {
|
||||
log.Printf("本地文件解密或传输失败 [%s]: %v", fullPath, err)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "本地文件解密失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSafeLocalPath 将库中存储路径解析为进程工作目录下的安全相对路径,阻止路径穿越。
|
||||
func resolveSafeLocalPath(realPath string) (string, error) {
|
||||
normalized := strings.ReplaceAll(strings.TrimSpace(realPath), "\\", "/")
|
||||
normalized = strings.TrimPrefix(normalized, "/")
|
||||
if normalized == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
// 禁止绝对路径与 Windows 盘符路径
|
||||
if filepath.IsAbs(normalized) || (len(normalized) >= 2 && normalized[1] == ':') {
|
||||
return "", errors.New("absolute path not allowed")
|
||||
}
|
||||
clean := filepath.Clean(normalized)
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
|
||||
absFile, err := filepath.Abs(clean)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absRoot, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sep := string(filepath.Separator)
|
||||
if absFile != absRoot && !strings.HasPrefix(absFile, absRoot+sep) {
|
||||
return "", errors.New("path outside workspace")
|
||||
}
|
||||
return absFile, nil
|
||||
}
|
||||
|
||||
// FTP代理(添加水印支持)
|
||||
func proxyFTPFile(c *gin.Context, ftpPath string, mimeType string, bucket models.Buckets, watermarkCfg watermark.WatermarkConfig) {
|
||||
// 清理FTP路径
|
||||
ftpPath = cleanFTPPath(ftpPath)
|
||||
|
||||
// 获取存储配置
|
||||
storageConfig := buckets.ConvertToFTPBucket(bucket.Config)
|
||||
|
||||
ftpUtil := ftp.NewFTPUtil(ftp.FTPConfig{
|
||||
Host: storageConfig.FTPHost,
|
||||
Port: storageConfig.FTPPort,
|
||||
User: storageConfig.FTPUser,
|
||||
Password: storageConfig.FTPPass,
|
||||
Timeout: 60,
|
||||
})
|
||||
defer func() {
|
||||
if err := ftpUtil.Close(); err != nil {
|
||||
if !strings.Contains(err.Error(), "227 Entering Passive Mode") {
|
||||
log.Printf("FTP连接关闭失败:%v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 获取文件流
|
||||
fileReader, _, err := ftpUtil.GetFileStreamReader(ftpPath)
|
||||
if err != nil {
|
||||
log.Printf("获取FTP文件流失败(路径:%s):%v", ftpPath, err)
|
||||
if strings.Contains(err.Error(), "550") {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, result.Error(502, "文件不存在或PureFTPd权限不足"))
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, result.Error(502, "FTP文件获取失败:"+err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := fileReader.Close(); err != nil {
|
||||
if !strings.Contains(err.Error(), "227 Entering Passive Mode") {
|
||||
log.Printf("FTP文件流关闭失败:%v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := serveStoredImage(c, fileReader, mimeType, bucket.Type, watermarkCfg); err != nil {
|
||||
log.Printf("FTP文件解密或传输失败(路径:%s):%v", ftpPath, err)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "FTP文件解密失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram 代理(添加水印支持)
|
||||
func ProxyTelegramFile(c *gin.Context, realPath string, telegramFileName string, mimeType string, bucket models.Buckets, replica *models.ImageStorage, watermarkCfg watermark.WatermarkConfig) {
|
||||
// 获取存储配置
|
||||
|
||||
storageConfig := buckets.ConvertToTelegramBucket(bucket.Config)
|
||||
|
||||
// 校验Telegram配置,弃用
|
||||
// if storageConfig.TGBotToken == "" {
|
||||
// log.Printf("Telegram BotToken 为空")
|
||||
// c.AbortWithStatusJSON(http.StatusBadGateway, result.Error(502, "telegram配置异常:bot token为空"))
|
||||
// return
|
||||
// }
|
||||
|
||||
// 获取数据库
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, result.Error(500, "获取数据库连接失败"))
|
||||
return
|
||||
}
|
||||
mainFileID := imageStorageMetadataString(replica, "tg_file_id")
|
||||
thumbnailFileID := imageStorageMetadataString(replica, "tg_thumbnail_file_id")
|
||||
if mainFileID == "" || (strings.Contains(realPath, "/thumbnails/") && thumbnailFileID == "") {
|
||||
var telegramModel models.ImageTeleGram
|
||||
if err := db.DB.Where("file_name = ?", telegramFileName).First(&telegramModel).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "record not found") {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, result.Error(502, "telegram文件不存在或file id无效"))
|
||||
} else {
|
||||
log.Printf("查询telegram文件信息失败:%v", err)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, result.Error(500, "查询telegram文件信息失败"))
|
||||
}
|
||||
return
|
||||
}
|
||||
if mainFileID == "" {
|
||||
mainFileID = telegramModel.TGFileId
|
||||
}
|
||||
if thumbnailFileID == "" {
|
||||
thumbnailFileID = telegramModel.TGThumbnailFileId
|
||||
}
|
||||
}
|
||||
|
||||
if mainFileID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, result.Error(502, "telegram文件无有效file id"))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 调用telegram包解析FileId
|
||||
// 检查是否为缩略图(链接格式:/uploads/Y/d/thumbnails/xxxxx.webp)
|
||||
var fileId string
|
||||
if strings.Contains(realPath, "/thumbnails/") {
|
||||
fileId = telegram.ParseFileIdFromTelegramPath(thumbnailFileID)
|
||||
} else {
|
||||
fileId = telegram.ParseFileIdFromTelegramPath(mainFileID)
|
||||
}
|
||||
|
||||
if fileId == "" {
|
||||
log.Printf("无效的Telegram路径:%s", realPath)
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, result.Error(502, "无效的telegram文件路径"))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 初始化Telegram客户端
|
||||
tgClient := telegram.NewClient(storageConfig.TGBotToken)
|
||||
tgClient.Timeout = 60 * time.Second // 延长超时
|
||||
tgClient.Retry = 3 // 重试次数
|
||||
|
||||
// 5. 调用telegram包获取文件流
|
||||
fileReader, err := telegram.GetTelegramFileStreamReader(tgClient, fileId)
|
||||
if err != nil {
|
||||
log.Printf("获取Telegram文件流失败(FileId:%s):%v", fileId, err)
|
||||
if strings.Contains(err.Error(), "file not found") || strings.Contains(err.Error(), "invalid file id") {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, result.Error(502, "telegram文件不存在或file id无效"))
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, result.Error(502, "telegram文件获取失败:"+err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := fileReader.Close(); err != nil {
|
||||
log.Printf("Telegram文件流关闭失败:%v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := serveStoredImage(c, fileReader, mimeType, bucket.Type, watermarkCfg); err != nil {
|
||||
log.Printf("Telegram文件解密或传输失败(FileId:%s):%v", fileId, err)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "Telegram文件解密失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func imageStorageMetadataString(replica *models.ImageStorage, key string) string {
|
||||
if replica == nil || replica.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := replica.Metadata[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func cleanFTPPath(path string) string {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
path = strings.ReplaceAll(path, "//", "/")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
return path
|
||||
}
|
||||
|
||||
// 辅助函数,校验来源
|
||||
func checkReferer(referer string, whiteList string, selfDomain string) bool {
|
||||
if referer == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
refererDomain, err := extractDomainFromReferer(referer)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
selfDomain = strings.TrimSpace(strings.ToLower(selfDomain))
|
||||
if selfDomain != "" {
|
||||
if refererDomain == selfDomain || strings.HasSuffix(refererDomain, "."+selfDomain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
whiteListDomains := strings.Split(strings.TrimSpace(whiteList), ",")
|
||||
|
||||
domainSet := make(map[string]bool)
|
||||
for _, d := range whiteListDomains {
|
||||
domain := strings.TrimSpace(d)
|
||||
if domain != "" {
|
||||
domainSet[domain] = true
|
||||
}
|
||||
}
|
||||
|
||||
for allowedDomain := range domainSet {
|
||||
if refererDomain == allowedDomain {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(refererDomain, "."+allowedDomain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func extractDomainFromReferer(referer string) (string, error) {
|
||||
if !strings.HasPrefix(referer, "http") {
|
||||
referer = "http://" + referer
|
||||
}
|
||||
|
||||
// 解析URL
|
||||
parsedURL, err := url.Parse(referer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
host := parsedURL.Hostname()
|
||||
|
||||
return strings.ToLower(host), nil
|
||||
}
|
||||
|
||||
// 辅助函数,获取本站域名
|
||||
func GetSelfDomain(c *gin.Context) string {
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
domain := strings.Split(host, ":")[0]
|
||||
return strings.ToLower(domain)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"net/http"
|
||||
"oneimg/backend/storage"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/watermark"
|
||||
"time"
|
||||
)
|
||||
|
||||
func proxyStorageFile(c *gin.Context, driver storage.Driver, ref storage.Ref, mimeType, kind string, wm watermark.WatermarkConfig) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
reader, err := driver.Get(ctx, ref)
|
||||
if err != nil {
|
||||
status, message := http.StatusBadGateway, "文件获取失败"
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
status, message = http.StatusNotFound, "文件不存在"
|
||||
case errors.Is(err, storage.ErrInvalidKey):
|
||||
status, message = http.StatusForbidden, "文件路径非法"
|
||||
case errors.Is(err, storage.ErrAccessDenied):
|
||||
status, message = http.StatusForbidden, "文件访问权限不足"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
status, message = http.StatusGatewayTimeout, "存储请求超时"
|
||||
}
|
||||
c.JSON(status, result.Error(status, message))
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
if err = serveStoredImage(c, reader, mimeType, kind, wm); err != nil {
|
||||
// Don't log credential-bearing upstream transport errors or signed URLs.
|
||||
log.Printf("[%s]图片传输失败", kind)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "文件读取失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/buckets"
|
||||
"oneimg/backend/utils/watermark"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
// Stable OneImg URLs never expire. Only the object-scoped R2 bearer URL does.
|
||||
const r2SignedURLLifetime = 30 * time.Minute
|
||||
|
||||
func safeR2RasterContentType(contentType string) bool {
|
||||
switch contentType {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "image/bmp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// redirectR2Object runs only after ImageProxy has checked the database record,
|
||||
// referer policy, successful access-source replica and bucket enabled state.
|
||||
// A HEAD request inspects legacy objects without transferring their image bytes.
|
||||
// Encrypted (octet-stream) objects and dynamic watermarks retain server processing.
|
||||
// false means the existing proxy must handle the request; true means it is done.
|
||||
func redirectR2Object(c *gin.Context, imageURL, bucketName string, bucket models.Buckets, client *minio.Client, wm watermark.WatermarkConfig) bool {
|
||||
if wm.Enable {
|
||||
return false
|
||||
}
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
c.Header("Allow", "GET, HEAD")
|
||||
c.AbortWithStatus(http.StatusMethodNotAllowed)
|
||||
return true
|
||||
}
|
||||
objectKey := strings.TrimPrefix(imageURL, "/")
|
||||
if objectKey == "" || bucketName == "" {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
info, err := client.StatObject(ctx, bucketName, objectKey, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
// Never silently consume server bandwidth after signing/metadata failures.
|
||||
errorCode := minio.ToErrorResponse(err).Code
|
||||
if errorCode == "NoSuchKey" || errorCode == "NoSuchObject" || errorCode == "NotFound" {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"error": "无法读取 R2 对象信息"})
|
||||
}
|
||||
return true
|
||||
}
|
||||
contentType := strings.ToLower(strings.TrimSpace(strings.SplitN(info.ContentType, ";", 2)[0]))
|
||||
// Existing writers mark encrypted originals/thumbnails as octet-stream.
|
||||
// Do not infer encryption from today's global setting: old encrypted files
|
||||
// must still decrypt even after users turn encryption off.
|
||||
// Redirect only inert raster formats. SVG (including legacy objects with
|
||||
// forged Content-Type metadata) must remain behind the sandboxed proxy.
|
||||
// Never hand an active document to a public CDN without our response policy.
|
||||
ext := strings.ToLower(path.Ext(objectKey))
|
||||
if ext == ".svg" || ext == ".svgz" || !safeR2RasterContentType(contentType) {
|
||||
return false
|
||||
}
|
||||
domain, cdnMode, err := buckets.R2CDNConfig(bucket.Config)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"error": "R2 CDN 配置无效,请检查存储设置"})
|
||||
return true
|
||||
}
|
||||
if domain != "" {
|
||||
base, _ := url.Parse(domain)
|
||||
if strings.EqualFold(base.Host, c.Request.Host) && base.Path == "" {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"error": "CDN 地址不能指向本站,避免循环跳转"})
|
||||
return true
|
||||
}
|
||||
}
|
||||
if domain != "" && cdnMode == buckets.R2CDNPublic && c.Query("download") != "1" {
|
||||
target, err := buckets.R2CDNObjectURL(domain, objectKey)
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Storage-Type", bucket.Type)
|
||||
c.Header("X-Image-Delivery", "r2-cdn-public")
|
||||
c.Header("Location", target)
|
||||
c.Status(http.StatusFound)
|
||||
return true
|
||||
}
|
||||
|
||||
params := url.Values{"response-cache-control": {"private, no-store"}}
|
||||
if c.Query("download") == "1" {
|
||||
params.Set("response-content-disposition", mime.FormatMediaType("attachment", map[string]string{
|
||||
"filename": path.Base(objectKey),
|
||||
}))
|
||||
}
|
||||
// Signing includes the HTTP method; GET signatures cannot service HEAD.
|
||||
// No arbitrary client query parameters or object paths are forwarded.
|
||||
signed, err := client.Presign(ctx, c.Request.Method, bucketName, objectKey, r2SignedURLLifetime, params)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"error": "无法生成 R2 临时访问链接"})
|
||||
return true
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Storage-Type", bucket.Type)
|
||||
c.Header("X-Image-Delivery", "r2-presigned")
|
||||
target := signed.String()
|
||||
if domain != "" && cdnMode == buckets.R2CDNSignedProxy {
|
||||
target, err = buckets.R2CDNSignedURL(domain, signed)
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
c.Header("X-Image-Delivery", "r2-cdn-signed-proxy")
|
||||
}
|
||||
c.Header("Location", target)
|
||||
// Empty redirect body: this server never opens or streams the image.
|
||||
c.Status(http.StatusFound)
|
||||
return true
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func Register(c *gin.Context) {
|
||||
|
||||
type RegisterReq struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=6,max=100"`
|
||||
Password string `json:"password" binding:"required,min=8,max=72"`
|
||||
PowToken string `json:"powToken"`
|
||||
TurnstileToken string `json:"turnstileToken"`
|
||||
CapToken string `json:"capToken"`
|
||||
@@ -40,20 +40,23 @@ func Register(c *gin.Context) {
|
||||
|
||||
if ok, errMsg, fallback := verifyHuman(c, settings, req.PowToken, req.TurnstileToken, req.CapToken); !ok {
|
||||
if fallback != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"code": 403,
|
||||
"message": errMsg,
|
||||
"data": gin.H{"verify_fallback": fallback},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, errMsg))
|
||||
c.JSON(http.StatusForbidden, result.Error(403, errMsg))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
if req.Username!=strings.TrimSpace(req.Username) || isTouristUsername(req.Username) || len(req.Password)>72 {
|
||||
c.JSON(400,result.Error(400,"用户名为保留名称或密码长度无效")); return
|
||||
}
|
||||
db := database.GetDB().DB
|
||||
|
||||
if db.Where("username = ?", req.Username).First(&models.User{}).Error == nil {
|
||||
if db.Where("username = ?", req.Username).First(&models.User{}).Error == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "用户名已存在"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/publicurl"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
"oneimg/backend/utils/securestorage"
|
||||
"oneimg/backend/utils/settings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 定义请求参数
|
||||
type UpdateSettingsRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
@@ -35,855 +10,3 @@ type UpdateSettingsRequest struct {
|
||||
type GetSettingsRequest struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// 十六进制颜色格式正则
|
||||
var hexColorRegex = regexp.MustCompile(`^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
||||
|
||||
// GetSettings 获取系统设置(按权限过滤敏感字段)。
|
||||
func GetSettings(c *gin.Context) {
|
||||
var req GetSettingsRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
user, exists := middlewares.GetCurrentUser(c)
|
||||
isAdmin := false
|
||||
if exists && (user.ID == models.SuperAdminID || user.Role == models.RoleAdmin) {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
settingModel, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取设置失败"))
|
||||
return
|
||||
}
|
||||
|
||||
responseSettings := secureconfig.SanitizeSettingsForResponse(settingModel)
|
||||
if effectiveURL, effectiveErr := oidcCallbackURL(settingModel); effectiveErr == nil {
|
||||
responseSettings["oidc_redirect_url_effective"] = effectiveURL
|
||||
}
|
||||
if effectiveURL, effectiveErr := casCallbackURL(settingModel); effectiveErr == nil {
|
||||
responseSettings["cas_service_url_effective"] = effectiveURL
|
||||
}
|
||||
|
||||
filtered := filterSettings(responseSettings, req.Keys)
|
||||
|
||||
permList := make([]string, 0)
|
||||
|
||||
if !isAdmin {
|
||||
if len(req.Keys) > 0 {
|
||||
for _, key := range req.Keys {
|
||||
requiredPerm := getSettingRequiredPerm(key)
|
||||
if requiredPerm != "" && !user.Permission.HasPermission(requiredPerm) {
|
||||
delete(filtered, key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for key := range filtered {
|
||||
requiredPerm := getSettingRequiredPerm(key)
|
||||
if requiredPerm != "" && !user.Permission.HasPermission(requiredPerm) {
|
||||
delete(filtered, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if exists {
|
||||
for _, code := range user.Permission.Codes {
|
||||
if strings.HasPrefix(code, "setting:") {
|
||||
permList = append(permList, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
permList = []string{
|
||||
"setting:upload", "setting:image", "setting:security",
|
||||
"setting:notification", "setting:api", "setting:seo",
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"message": "ok",
|
||||
"data": filtered,
|
||||
"setting_permissions": permList,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLoginSettings 获取登录页公开配置。
|
||||
func GetLoginSettings(c *gin.Context) {
|
||||
settingModel, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取设置失败"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, result.Success("ok",
|
||||
map[string]any{
|
||||
"verify_method": effectiveVerifyMethodWithFallback(settingModel),
|
||||
"pow_verify": settingModel.PowVerify,
|
||||
"turnstile_site_key": settingModel.TurnstileSiteKey,
|
||||
"tourist": settingModel.Tourist,
|
||||
"start_register": settingModel.StartRegister,
|
||||
"oidc_enabled": oidcSettingsReady(settingModel),
|
||||
"oidc_display_name": externalLoginDisplayName(settingModel.OIDCDisplayName, "OIDC 登录"),
|
||||
"cas_enabled": casSettingsReady(settingModel),
|
||||
"cas_display_name": externalLoginDisplayName(settingModel.CASDisplayName, "CAS 登录"),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// GetSEOSettings 获取站点 SEO 公开配置。
|
||||
func GetSEOSettings(c *gin.Context) {
|
||||
settingModel, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取设置失败"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, result.Success("ok",
|
||||
map[string]any{
|
||||
"seo_title": settingModel.SEOTitle,
|
||||
"seo_description": settingModel.SEODescription,
|
||||
"seo_keywords": settingModel.SEOKeywords,
|
||||
"seo_icp": settingModel.SEOICP,
|
||||
"public_security": settingModel.PublicSecurity,
|
||||
"seo_icon": settingModel.SEOicon,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
func UpdateSettings(c *gin.Context) {
|
||||
var req UpdateSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, result.Error(400, "请求参数错误: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if req.Value == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "设置值不能为空"))
|
||||
return
|
||||
}
|
||||
user, exists := middlewares.GetCurrentUser(c)
|
||||
if !exists {
|
||||
c.JSON(401, result.Error(401, "未登录"))
|
||||
return
|
||||
}
|
||||
if user.ID != models.SuperAdminID {
|
||||
requiredPerm := getSettingRequiredPerm(req.Key)
|
||||
if requiredPerm == "" {
|
||||
c.JSON(400, result.Error(400, "未知的设置项"))
|
||||
return
|
||||
}
|
||||
requiredPermName := models.GetPermissionName(requiredPerm)
|
||||
if !user.Permission.HasPermission(requiredPerm) {
|
||||
c.JSON(403, result.Error(403, "无权修改该设置项,需要权限: "+requiredPermName))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 查询是否有该设置项
|
||||
settingModel, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取设置失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验设置数据
|
||||
if err := validateSettingData(req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
fieldName, fieldType, err := findSettingsField(reflect.TypeOf(settingModel), req.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if secureconfig.IsSettingsSensitiveKey(req.Key) {
|
||||
if err := updateSensitiveSettingsField(&settingModel, req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
} else if err := updateSettingsField(&settingModel, req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新设置项
|
||||
db := database.GetDB().DB
|
||||
|
||||
updateColumn, updateValue, err := buildSettingsUpdate(req.Key, req.Value, fieldName, fieldType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if updateValue == nil && secureconfig.IsSettingsSensitiveKey(req.Key) {
|
||||
c.JSON(200, result.Success("无需更新", nil))
|
||||
return
|
||||
}
|
||||
|
||||
if err := ensureSettingsColumn(db, updateColumn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "数据库字段兼容处理失败"))
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&settingModel).Update(updateColumn, updateValue).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "更新失败"))
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, result.Success("更新成功", nil))
|
||||
}
|
||||
|
||||
// 辅助函数,筛选设置项
|
||||
func filterSettings(settingsMap map[string]any, keys []string) map[string]any {
|
||||
if len(keys) == 0 {
|
||||
return settingsMap
|
||||
}
|
||||
|
||||
filteredSettings := make(map[string]any)
|
||||
for key, value := range settingsMap {
|
||||
if slices.Contains(keys, key) {
|
||||
filteredSettings[key] = value
|
||||
}
|
||||
}
|
||||
return filteredSettings
|
||||
}
|
||||
|
||||
func findSettingsField(typ reflect.Type, key string) (string, reflect.Type, error) {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == key || field.Name == key {
|
||||
return field.Name, field.Type, nil
|
||||
}
|
||||
}
|
||||
return "", nil, fmt.Errorf("设置项 %s 不存在", key)
|
||||
}
|
||||
|
||||
func updateSensitiveSettingsField(settings *models.Settings, key string, value any) error {
|
||||
stringValue := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
switch key {
|
||||
case "api_token":
|
||||
settings.APIToken = ""
|
||||
settings.APITokenHash = stringValue
|
||||
return nil
|
||||
case "tg_bot_token":
|
||||
settings.TGBotToken = stringValue
|
||||
return nil
|
||||
case "oidc_client_secret":
|
||||
settings.OIDCClientSecret = stringValue
|
||||
return nil
|
||||
case "turnstile_secret_key":
|
||||
settings.TurnstileSecret = stringValue
|
||||
return nil
|
||||
case "cloudflare_api_token":
|
||||
settings.CloudflareAPIToken = stringValue
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("设置项 %s 不支持敏感更新", key)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSettingsUpdate(key string, value any, fieldName string, fieldType reflect.Type) (string, any, error) {
|
||||
normalizedValue, err := secureconfig.NormalizeSettingValue(key, value)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "api_token":
|
||||
return "api_token_hash", normalizedValue, nil
|
||||
case "tg_bot_token", "oidc_client_secret", "turnstile_secret_key", "cloudflare_api_token":
|
||||
return key, normalizedValue, nil
|
||||
case "public_image_domain":
|
||||
domain, err := publicurl.NormalizeDomain(fmt.Sprintf("%v", value))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "public_image_domain", domain, nil
|
||||
case "oidc_issuer":
|
||||
normalized, err := normalizeOIDCIssuer(fmt.Sprintf("%v", value))
|
||||
return "oidc_issuer", normalized, err
|
||||
case "oidc_redirect_url":
|
||||
normalized, err := normalizeOIDCCallbackURL(fmt.Sprintf("%v", value))
|
||||
return "oidc_redirect_url", normalized, err
|
||||
case "cas_server_url":
|
||||
normalized, err := normalizeCASServerURL(fmt.Sprintf("%v", value))
|
||||
return "cas_server_url", normalized, err
|
||||
case "cas_service_url":
|
||||
normalized, err := normalizeCASCallbackURL(fmt.Sprintf("%v", value))
|
||||
return "cas_service_url", normalized, err
|
||||
case "oidc_scopes":
|
||||
normalized, err := normalizeOIDCScopes(fmt.Sprintf("%v", value))
|
||||
return "oidc_scopes", normalized, err
|
||||
case "oidc_client_id", "oidc_username_claim", "oidc_display_name", "oidc_super_admin_username", "cas_display_name", "cas_super_admin_username":
|
||||
return getSettingsColumnName(fieldName), strings.TrimSpace(fmt.Sprintf("%v", value)), nil
|
||||
default:
|
||||
convertedValue, convertErr := convertValueToTargetType(key, value, fieldType)
|
||||
if convertErr != nil {
|
||||
return "", nil, convertErr
|
||||
}
|
||||
return getSettingsColumnName(fieldName), convertedValue, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getSettingsColumnName(fieldName string) string {
|
||||
settingsType := reflect.TypeOf(models.Settings{})
|
||||
if field, ok := settingsType.FieldByName(fieldName); ok {
|
||||
gormTag := field.Tag.Get("gorm")
|
||||
for _, part := range strings.Split(gormTag, ";") {
|
||||
if strings.HasPrefix(part, "column:") {
|
||||
return strings.TrimPrefix(part, "column:")
|
||||
}
|
||||
}
|
||||
}
|
||||
return fieldName
|
||||
}
|
||||
|
||||
func ensureSettingsColumn(db *gorm.DB, columnName string) error {
|
||||
fieldName, err := getSettingsFieldNameByColumn(columnName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Settings{}, columnName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[数据库兼容] settings.%s 字段不存在,尝试创建", columnName)
|
||||
if err := db.Migrator().AddColumn(&models.Settings{}, fieldName); err != nil {
|
||||
return fmt.Errorf("创建 settings.%s 字段失败: %w", columnName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSettingsFieldNameByColumn(columnName string) (string, error) {
|
||||
settingsType := reflect.TypeOf(models.Settings{})
|
||||
for i := 0; i < settingsType.NumField(); i++ {
|
||||
field := settingsType.Field(i)
|
||||
if getSettingsColumnName(field.Name) == columnName || field.Name == columnName {
|
||||
return field.Name, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("设置字段 %s 不存在", columnName)
|
||||
}
|
||||
|
||||
func updateSettingsField(settings *models.Settings, key string, value any) error {
|
||||
// 获取结构体反射值(指针解引用)
|
||||
val := reflect.ValueOf(settings).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
// 1. 遍历结构体字段,匹配JSON Tag或字段名
|
||||
var targetField reflect.Value
|
||||
var fieldType reflect.Type
|
||||
found := false
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
// 优先匹配JSON Tag(如 json:"tourist")
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == key || field.Name == key {
|
||||
targetField = val.Field(i)
|
||||
fieldType = field.Type
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 校验字段是否存在
|
||||
if !found {
|
||||
return fmt.Errorf("设置项 %s 不存在", key)
|
||||
}
|
||||
|
||||
// 2. 校验字段是否可修改(必须是导出字段)
|
||||
if !targetField.CanSet() {
|
||||
return fmt.Errorf("设置项 %s 不可修改", key)
|
||||
}
|
||||
|
||||
// 3. 处理nil值(避免panic)
|
||||
if value == nil {
|
||||
return fmt.Errorf("设置项 %s 的值不能为空", key)
|
||||
}
|
||||
|
||||
// 4. 转换value类型为字段实际类型
|
||||
convertedValue, err := convertValueToTargetType(key, value, fieldType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
valueVal := reflect.ValueOf(convertedValue)
|
||||
|
||||
// 5. 设置字段值
|
||||
targetField.Set(valueVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertValueToTargetType(key string, value any, targetType reflect.Type) (any, error) {
|
||||
valueVal := reflect.ValueOf(value)
|
||||
valueType := valueVal.Type()
|
||||
|
||||
// 类型已匹配,直接返回
|
||||
if valueType == targetType {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// 场景1:反射支持直接转换(如 int→float64、bool→int 等)
|
||||
if valueType.ConvertibleTo(targetType) {
|
||||
return valueVal.Convert(targetType).Interface(), nil
|
||||
}
|
||||
|
||||
// 场景2:反射不支持直接转换,手动处理常见类型解析
|
||||
switch targetType.Kind() {
|
||||
// 处理 string → float64(解决watermark_opac的核心问题)
|
||||
case reflect.Float64:
|
||||
if valueType.Kind() == reflect.String {
|
||||
strVal := valueVal.String()
|
||||
floatVal, err := strconv.ParseFloat(strVal, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("设置项 %s 类型转换失败,期望 float64,实际 string(值:%s),错误:%v",
|
||||
key, strVal, err)
|
||||
}
|
||||
return floatVal, nil
|
||||
}
|
||||
|
||||
// 处理 string → int/int64
|
||||
case reflect.Int:
|
||||
if valueType.Kind() == reflect.String {
|
||||
strVal := valueVal.String()
|
||||
intVal, err := strconv.Atoi(strVal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("设置项 %s 类型转换失败,期望 int,实际 string(值:%s),错误:%v",
|
||||
key, strVal, err)
|
||||
}
|
||||
return intVal, nil
|
||||
}
|
||||
case reflect.Int64:
|
||||
if valueType.Kind() == reflect.String {
|
||||
strVal := valueVal.String()
|
||||
int64Val, err := strconv.ParseInt(strVal, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("设置项 %s 类型转换失败,期望 int64,实际 string(值:%s),错误:%v",
|
||||
key, strVal, err)
|
||||
}
|
||||
return int64Val, nil
|
||||
}
|
||||
|
||||
// 处理 string → bool
|
||||
case reflect.Bool:
|
||||
if valueType.Kind() == reflect.String {
|
||||
strVal := valueVal.String()
|
||||
boolVal, err := strconv.ParseBool(strVal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("设置项 %s 类型转换失败,期望 bool,实际 string(值:%s),错误:%v",
|
||||
key, strVal, err)
|
||||
}
|
||||
return boolVal, nil
|
||||
}
|
||||
case reflect.String:
|
||||
// 所有基础类型都可以转为string
|
||||
return fmt.Sprintf("%v", value), nil
|
||||
}
|
||||
|
||||
// 不支持的转换类型
|
||||
return nil, fmt.Errorf("设置项 %s 类型不匹配,期望 %s,实际 %T",
|
||||
key, targetType, value)
|
||||
}
|
||||
|
||||
func validateSettingData(key string, value any) error {
|
||||
if settingDisabledByPublicDomain(key) {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if publicurl.HasDomain(setting) {
|
||||
return fmt.Errorf("已配置图片直链域名,该设置不会生效,请先清空图片直链域名")
|
||||
}
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "oidc_issuer":
|
||||
_, err := normalizeOIDCIssuer(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "oidc_redirect_url":
|
||||
_, err := normalizeOIDCCallbackURL(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "cas_service_url":
|
||||
_, err := normalizeCASCallbackURL(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "cas_server_url":
|
||||
_, err := normalizeCASServerURL(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "oidc_scopes":
|
||||
_, err := normalizeOIDCScopes(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "oidc_username_claim":
|
||||
claim := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if claim == "" || !oidcClaimNameRegex.MatchString(claim) {
|
||||
return fmt.Errorf("OIDC 用户名 Claim 格式不正确")
|
||||
}
|
||||
case "oidc_client_id":
|
||||
if len(strings.TrimSpace(fmt.Sprintf("%v", value))) > 512 {
|
||||
return fmt.Errorf("OIDC Client ID 过长")
|
||||
}
|
||||
case "oidc_client_secret":
|
||||
if len(strings.TrimSpace(fmt.Sprintf("%v", value))) > 4096 {
|
||||
return fmt.Errorf("OIDC Client Secret 过长")
|
||||
}
|
||||
case "oidc_display_name", "cas_display_name":
|
||||
name := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if len([]rune(name)) > 40 {
|
||||
return fmt.Errorf("登录按钮名称不能超过40个字符")
|
||||
}
|
||||
case "oidc_super_admin_username", "cas_super_admin_username":
|
||||
if len([]rune(strings.TrimSpace(fmt.Sprintf("%v", value)))) > 255 {
|
||||
return fmt.Errorf("超级管理员映射用户名不能超过255个字符")
|
||||
}
|
||||
case "oidc_enable":
|
||||
enabled, err := convertValueToTargetType(key, value, reflect.TypeOf(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled.(bool) {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil || !oidcSettingsComplete(setting) {
|
||||
return fmt.Errorf("请先完整配置 OIDC Issuer、Client ID、Client Secret 和回调地址")
|
||||
}
|
||||
}
|
||||
case "cas_enable":
|
||||
enabled, err := convertValueToTargetType(key, value, reflect.TypeOf(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled.(bool) {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil || !casSettingsComplete(setting) {
|
||||
return fmt.Errorf("请先完整配置 CAS Server URL 和回调地址")
|
||||
}
|
||||
}
|
||||
case "verify_method":
|
||||
method := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
switch method {
|
||||
case models.VerifyMethodNone, models.VerifyMethodPOW, models.VerifyMethodTurnstile, models.VerifyMethodCappow:
|
||||
default:
|
||||
return fmt.Errorf("验证方式不合法,可选:无验证、在线POW、Turnstile、cap-pow")
|
||||
}
|
||||
// 允许先切换方式、后配置密钥:若 Turnstile 密钥未配置,登录/注册时
|
||||
// 会提示「Turnstile 验证尚未配置」,避免配置死锁。
|
||||
case "turnstile_site_key":
|
||||
siteKey := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if siteKey == "" {
|
||||
return nil // 允许清空
|
||||
}
|
||||
// 保存前通过 Cloudflare 管理 API 校验公钥真实存在(需先配置 API Token 与账号 ID)
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if err := validateTurnstileSiteKeyAPI(siteKey, setting.CloudflareAPIToken, setting.CloudflareAccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "turnstile_secret_key":
|
||||
// 留空表示不修改(沿用已配置密钥),跳过校验
|
||||
if strings.TrimSpace(fmt.Sprintf("%v", value)) == "" {
|
||||
return nil
|
||||
}
|
||||
// 保存前发起模拟验证请求,确认密钥被 Cloudflare 识别
|
||||
if err := probeTurnstileSecret(fmt.Sprintf("%v", value)); err != nil {
|
||||
return err
|
||||
}
|
||||
case "cappow_difficulty":
|
||||
diff, err := settingValueToInt(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cap-pow 难度必须为整数")
|
||||
}
|
||||
if diff < 1 || diff > 8 {
|
||||
return fmt.Errorf("cap-pow 难度必须在 1-8 之间")
|
||||
}
|
||||
case "encrypted_storage":
|
||||
enabled, err := convertValueToTargetType(key, value, reflect.TypeOf(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled.(bool) {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if strings.TrimSpace(setting.PublicImageDomain) != "" {
|
||||
return fmt.Errorf("加密存储要求所有图片经程序解密,请先清空图片直链域名")
|
||||
}
|
||||
if _, err := securestorage.Encrypt(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "public_image_domain":
|
||||
domain, err := publicurl.NormalizeDomain(fmt.Sprintf("%v", value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if setting.EncryptedStorage {
|
||||
return fmt.Errorf("加密存储已开启,图片必须经程序解密,不能配置直链域名")
|
||||
}
|
||||
bucketType, err := getBucketTypeByID(setting.DefaultStorage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !publicurl.SupportsStorage(bucketType) {
|
||||
return fmt.Errorf("当前默认存储不支持图片直链域名,请先切换到 S3 或 R2 存储")
|
||||
}
|
||||
|
||||
case "watermark_text":
|
||||
// 1. 水印文字长度校验(兼容字符串类型)
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("水印文字必须是字符串类型,实际类型:%T", value)
|
||||
}
|
||||
if len(text) > 20 {
|
||||
return fmt.Errorf("水印文字长度不能超过20个字符(当前:%d)", len(text))
|
||||
}
|
||||
|
||||
case "watermark_size":
|
||||
// 2. 水印字体大小校验
|
||||
var size int
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
size = v
|
||||
case string:
|
||||
// 字符串转int
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return errors.New("水印字体大小不能为空")
|
||||
}
|
||||
num, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("水印字体大小必须是整数(当前值:%s)", v)
|
||||
}
|
||||
size = num
|
||||
default:
|
||||
return fmt.Errorf("水印字体大小必须是整数或数字字符串,实际类型:%T", value)
|
||||
}
|
||||
// 范围校验
|
||||
if size < 1 || size > 100 {
|
||||
return fmt.Errorf("水印字体大小必须在1-100之间(当前:%d)", size)
|
||||
}
|
||||
|
||||
case "watermark_color":
|
||||
// 3. 水印颜色校验(防空 + 十六进制格式)
|
||||
color, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("水印颜色必须是字符串类型,实际类型:%T", value)
|
||||
}
|
||||
color = strings.TrimSpace(color)
|
||||
if color == "" {
|
||||
return errors.New("水印字体颜色不能为空")
|
||||
}
|
||||
if !hexColorRegex.MatchString(color) {
|
||||
return fmt.Errorf("水印颜色格式错误,请使用十六进制颜色码,当前值:%s", color)
|
||||
}
|
||||
|
||||
case "watermark_opac":
|
||||
// 4. 水印透明度校验(兼容字符串/float64/int,转为float64后校验0-1)
|
||||
var opac float64
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
opac = v
|
||||
case int:
|
||||
opac = float64(v) // int转float64(如 1 → 1.0)
|
||||
case string:
|
||||
// 字符串转float64
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return errors.New("水印透明度不能为空")
|
||||
}
|
||||
num, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("水印透明度必须是数字(当前值:%s)", v)
|
||||
}
|
||||
opac = num
|
||||
default:
|
||||
return fmt.Errorf("水印透明度必须是数字或数字字符串,实际类型:%T", value)
|
||||
}
|
||||
// 范围校验(0.0-1.0)
|
||||
if opac < 0.0 || opac > 1.0 {
|
||||
return fmt.Errorf("水印透明度必须在0-1之间(当前:%.2f)", opac)
|
||||
}
|
||||
|
||||
case "watermark_pos":
|
||||
// 5. 水印位置校验(防空 + 合法值)
|
||||
pos, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("水印位置必须是字符串类型,实际类型:%T", value)
|
||||
}
|
||||
pos = strings.TrimSpace(pos)
|
||||
if pos == "" {
|
||||
return errors.New("水印位置不能为空")
|
||||
}
|
||||
// 合法位置集合
|
||||
validPos := map[string]bool{
|
||||
"top-left": true,
|
||||
"top-right": true,
|
||||
"bottom-left": true,
|
||||
"bottom-right": true,
|
||||
"center": true,
|
||||
}
|
||||
if !validPos[pos] {
|
||||
return fmt.Errorf("水印位置参数不合法")
|
||||
}
|
||||
case "default_storage":
|
||||
// 检查存储配置是否存在
|
||||
id, err := settingValueToInt(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", "解析失败: "+err.Error())
|
||||
}
|
||||
|
||||
bucketType, err := getBucketTypeByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if publicurl.HasDomain(setting) && !publicurl.SupportsStorage(bucketType) {
|
||||
return fmt.Errorf("图片直链域名仅支持 S3/R2 存储,请先清空该配置")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingDisabledByPublicDomain(key string) bool {
|
||||
switch key {
|
||||
case "watermark_enable",
|
||||
"watermark_text",
|
||||
"watermark_size",
|
||||
"watermark_color",
|
||||
"watermark_opac",
|
||||
"watermark_pos",
|
||||
"referer_white_enable",
|
||||
"referer_white_list":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func getBucketTypeByID(id int) (string, error) {
|
||||
db := database.GetDB().DB
|
||||
var bucket models.Buckets
|
||||
if err := db.First(&bucket, id).Error; err != nil {
|
||||
return "", fmt.Errorf("存储桶不存在")
|
||||
}
|
||||
if bucket.Disabled {
|
||||
return "", fmt.Errorf("存储源已停用,请先启用")
|
||||
}
|
||||
return bucket.Type, nil
|
||||
}
|
||||
|
||||
func settingValueToInt(value any) (int, error) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, nil
|
||||
case int64:
|
||||
return int(v), nil
|
||||
case float64:
|
||||
return int(v), nil
|
||||
case string:
|
||||
num64, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(num64), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("类型错误:%T", value)
|
||||
}
|
||||
}
|
||||
|
||||
// SettingKeyPermissionMap 设置项key对应的权限码映射
|
||||
var SettingKeyPermissionMap = map[string]string{
|
||||
// --- 上传与存储 ---
|
||||
"default_storage": "setting:upload",
|
||||
"public_image_domain": "setting:upload",
|
||||
"default_path": "setting:upload",
|
||||
"file_name": "setting:upload",
|
||||
"max_file_size": "setting:upload",
|
||||
"allowed_types": "setting:upload",
|
||||
"multi_storage_sync": "setting:upload",
|
||||
"encrypted_storage": "setting:upload",
|
||||
"save_original_name": "setting:upload",
|
||||
|
||||
// --- 图片处理 ---
|
||||
"watermark_enable": "setting:image",
|
||||
"watermark_text": "setting:image",
|
||||
"watermark_size": "setting:image",
|
||||
"watermark_color": "setting:image",
|
||||
"watermark_opac": "setting:image",
|
||||
"watermark_pos": "setting:image",
|
||||
"compress_image": "setting:image",
|
||||
"save_webp": "setting:image",
|
||||
"thumbnail": "setting:image",
|
||||
|
||||
// --- 安全与登录 ---
|
||||
"pow_verify": "setting:security",
|
||||
"verify_method": "setting:security",
|
||||
"turnstile_site_key": "setting:security",
|
||||
"turnstile_secret_key": "setting:security",
|
||||
"cloudflare_api_token": "setting:security",
|
||||
"cloudflare_account_id": "setting:security",
|
||||
"cappow_difficulty": "setting:security",
|
||||
"tourist": "setting:security",
|
||||
"start_register": "setting:security",
|
||||
"referer_white_enable": "setting:security",
|
||||
"referer_white_list": "setting:security",
|
||||
"oidc_enable": "setting:security",
|
||||
"oidc_issuer": "setting:security",
|
||||
"oidc_client_id": "setting:security",
|
||||
"oidc_client_secret": "setting:security",
|
||||
"oidc_auto_provision": "setting:security",
|
||||
"oidc_super_admin_username": "setting:security",
|
||||
"oidc_redirect_url": "setting:security",
|
||||
"oidc_scopes": "setting:security",
|
||||
"oidc_username_claim": "setting:security",
|
||||
"oidc_display_name": "setting:security",
|
||||
"cas_enable": "setting:security",
|
||||
"cas_server_url": "setting:security",
|
||||
"cas_auto_provision": "setting:security",
|
||||
"cas_super_admin_username": "setting:security",
|
||||
"cas_service_url": "setting:security",
|
||||
"cas_display_name": "setting:security",
|
||||
|
||||
// --- 通知 ---
|
||||
"tg_notice": "setting:notification",
|
||||
"tg_bot_token": "setting:notification",
|
||||
"tg_receivers": "setting:notification",
|
||||
"tg_notice_text": "setting:notification",
|
||||
|
||||
// --- API ---
|
||||
"start_api": "setting:api",
|
||||
"api_token": "setting:api",
|
||||
"random_graph": "setting:api",
|
||||
|
||||
// --- 站点SEO ---
|
||||
"seo_title": "setting:seo",
|
||||
"seo_description": "setting:seo",
|
||||
"seo_keywords": "setting:seo",
|
||||
"seo_icp": "setting:seo",
|
||||
"public_security": "setting:seo",
|
||||
"seo_icon": "setting:seo",
|
||||
}
|
||||
|
||||
// getSettingRequiredPerm
|
||||
func getSettingRequiredPerm(key string) string {
|
||||
if perm, ok := SettingKeyPermissionMap[key]; ok {
|
||||
return perm
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/powverify"
|
||||
"oneimg/backend/utils/uploadpolicy"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type settingFieldMetadata struct {
|
||||
Type string `json:"type"`
|
||||
Default any `json:"default"`
|
||||
Min int `json:"min,omitempty"`
|
||||
Max int `json:"max,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func configurableFieldMetadata() map[string]settingFieldMetadata {
|
||||
return map[string]settingFieldMetadata{
|
||||
"max_upload_files": {Type: "integer", Default: config.DefaultMaxUploadFiles, Min: 1, Max: config.HardMaxUploadFiles, Permission: "setting:upload", Description: "每次请求最多上传文件数"},
|
||||
"tag_max_length": {Type: "integer", Default: config.DefaultTagMaxLength, Min: 1, Max: config.HardTagMaxLength, Permission: "setting:upload", Description: "标签最多 Unicode 字符数"},
|
||||
"random_image_limit": {Type: "integer", Default: config.DefaultRandomImageLimit, Min: 1, Max: config.HardRandomImageLimit, Permission: "setting:api", Description: "随机图片接口每次最多返回数量"},
|
||||
"pow_script_url": {Type: "string", Default: "https://cha.eta.im/static/js/pow.min.js", Permission: "setting:security", Description: "浏览器运行的可信公共 HTTPS PoW 脚本;空值沿用旧提供方"},
|
||||
"pow_widget_url": {Type: "string", Default: "https://cha.eta.im/", Permission: "setting:security", Description: "同提供方的公共 HTTPS challenge 地址;空值沿用旧提供方"},
|
||||
"pow_verify_url": {Type: "string", Default: config.LegacyPowVerifyURL, Permission: "setting:security", Description: "公共 HTTPS(443)验证地址;空值沿用旧提供方,不允许凭据、查询参数、片段或重定向"},
|
||||
"pow_verify_timeout_seconds": {Type: "integer", Default: config.DefaultPowVerifyTimeoutSeconds, Min: 1, Max: config.HardPowVerifyTimeoutSeconds, Permission: "setting:security", Description: "旧 POW 提供方请求超时(秒)"},
|
||||
"pow_local_fallback": {Type: "boolean", Default: false, Permission: "setting:security", Description: "显式改用本地 cap-pow,不联系旧 POW 提供方;默认关闭以兼容旧站点"},
|
||||
}
|
||||
}
|
||||
func publicLimitSettings(s models.Settings) map[string]any {
|
||||
return map[string]any{"max_upload_files": uploadpolicy.MaxFiles(s), "tag_max_length": uploadpolicy.TagLength(s), "random_image_limit": uploadpolicy.RandomLimit(s)}
|
||||
}
|
||||
func addConfigurableSettings(response map[string]any, s models.Settings) {
|
||||
for key, value := range publicLimitSettings(s) {
|
||||
response[key] = value
|
||||
}
|
||||
response["pow_script_url"] = effectivePowScriptURL(s)
|
||||
response["pow_widget_url"] = effectivePowWidgetURL(s)
|
||||
response["pow_verify_url"] = uploadpolicy.PowURL(s)
|
||||
response["pow_verify_timeout_seconds"] = uploadpolicy.PowTimeoutSeconds(s)
|
||||
response["pow_local_fallback"] = s.PowLocalFallback
|
||||
}
|
||||
func validateConfigurableSetting(key string, value any) (bool, error) {
|
||||
meta, ok := configurableFieldMetadata()[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
switch meta.Type {
|
||||
case "integer":
|
||||
n, err := strictSettingInt(value)
|
||||
if err != nil || n < meta.Min || n > meta.Max {
|
||||
return true, fmt.Errorf("%s 必须是 %d-%d 之间的整数", key, meta.Min, meta.Max)
|
||||
}
|
||||
case "string":
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return true, fmt.Errorf("%s 必须为字符串", key)
|
||||
}
|
||||
_, err := powverify.NormalizeURL(text)
|
||||
return true, err
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return true, fmt.Errorf("%s 必须为布尔值", key)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Reject fractions/NaN/overflows rather than reflect's silent numeric truncation.
|
||||
func strictSettingInt(value any) (int, error) {
|
||||
var text string
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, nil
|
||||
case int64:
|
||||
text = strconv.FormatInt(v, 10)
|
||||
case json.Number:
|
||||
text = v.String()
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v {
|
||||
return 0, fmt.Errorf("必须是有限整数")
|
||||
}
|
||||
text = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case string:
|
||||
text = strings.TrimSpace(v)
|
||||
default:
|
||||
return 0, fmt.Errorf("必须是整数")
|
||||
}
|
||||
n, err := strconv.ParseInt(text, 10, strconv.IntSize)
|
||||
return int(n), err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"oneimg/backend/models"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 辅助函数,筛选设置项
|
||||
func filterSettings(settingsMap map[string]any, keys []string) map[string]any {
|
||||
if len(keys) == 0 {
|
||||
return settingsMap
|
||||
}
|
||||
|
||||
filteredSettings := make(map[string]any)
|
||||
for key, value := range settingsMap {
|
||||
if slices.Contains(keys, key) {
|
||||
filteredSettings[key] = value
|
||||
}
|
||||
}
|
||||
return filteredSettings
|
||||
}
|
||||
|
||||
func filterDelegatedSettings(response map[string]any, permission models.Permission) {
|
||||
for key := range response {
|
||||
required := getSettingRequiredPerm(key)
|
||||
if required == "" || !permission.HasPermission(required) {
|
||||
delete(response, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SettingKeyPermissionMap 设置项key对应的权限码映射
|
||||
var SettingKeyPermissionMap = map[string]string{
|
||||
// --- 上传与存储 ---
|
||||
"default_storage": "setting:upload",
|
||||
"guest_storage": "setting:upload",
|
||||
"public_image_domain": "setting:upload",
|
||||
"default_path": "setting:upload",
|
||||
"file_name": "setting:upload",
|
||||
"max_file_size": "setting:upload",
|
||||
"max_upload_files": "setting:upload",
|
||||
"tag_max_length": "setting:upload",
|
||||
"allowed_types": "setting:upload",
|
||||
"multi_storage_sync": "setting:upload",
|
||||
"encrypted_storage": "setting:upload",
|
||||
"save_original_name": "setting:upload",
|
||||
|
||||
// --- 图片处理 ---
|
||||
"watermark_enable": "setting:image",
|
||||
"watermark_text": "setting:image",
|
||||
"watermark_size": "setting:image",
|
||||
"watermark_color": "setting:image",
|
||||
"watermark_opac": "setting:image",
|
||||
"watermark_pos": "setting:image",
|
||||
"compress_image": "setting:image",
|
||||
"save_webp": "setting:image",
|
||||
"thumbnail": "setting:image",
|
||||
|
||||
// --- 安全与登录 ---
|
||||
"pow_verify": "setting:security",
|
||||
"pow_script_url": "setting:security",
|
||||
"pow_widget_url": "setting:security",
|
||||
"pow_verify_url": "setting:security",
|
||||
"pow_verify_timeout_seconds": "setting:security",
|
||||
"pow_local_fallback": "setting:security",
|
||||
"verify_method": "setting:security",
|
||||
"turnstile_site_key": "setting:security",
|
||||
"turnstile_secret_key": "setting:security",
|
||||
"cloudflare_api_token": "setting:security",
|
||||
"cloudflare_account_id": "setting:security",
|
||||
"cappow_difficulty": "setting:security",
|
||||
"tourist": "setting:security",
|
||||
"start_register": "setting:security",
|
||||
"referer_white_enable": "setting:security",
|
||||
"referer_white_list": "setting:security",
|
||||
"referer_allow_empty": "setting:security",
|
||||
"oidc_redirect_url_effective": "setting:security",
|
||||
"cas_service_url_effective": "setting:security",
|
||||
"api_credentials": "setting:api",
|
||||
"oidc_enable": "setting:security",
|
||||
"oidc_issuer": "setting:security",
|
||||
"oidc_client_id": "setting:security",
|
||||
"oidc_client_secret": "setting:security",
|
||||
"oidc_auto_provision": "setting:security",
|
||||
"oidc_super_admin_username": "setting:security",
|
||||
"oidc_redirect_url": "setting:security",
|
||||
"oidc_scopes": "setting:security",
|
||||
"oidc_username_claim": "setting:security",
|
||||
"oidc_display_name": "setting:security",
|
||||
"cas_enable": "setting:security",
|
||||
"cas_server_url": "setting:security",
|
||||
"cas_auto_provision": "setting:security",
|
||||
"cas_super_admin_username": "setting:security",
|
||||
"cas_service_url": "setting:security",
|
||||
"cas_display_name": "setting:security",
|
||||
|
||||
// --- 通知 ---
|
||||
"tg_notice": "setting:notification",
|
||||
"tg_bot_token": "setting:notification",
|
||||
"tg_receivers": "setting:notification",
|
||||
"tg_notice_text": "setting:notification",
|
||||
|
||||
// --- API ---
|
||||
"start_api": "setting:api",
|
||||
"api_token": "setting:api",
|
||||
"random_graph": "setting:api",
|
||||
"random_image_limit": "setting:api",
|
||||
|
||||
// --- 站点SEO ---
|
||||
"seo_title": "setting:seo",
|
||||
"seo_description": "setting:seo",
|
||||
"seo_keywords": "setting:seo",
|
||||
"seo_icp": "setting:seo",
|
||||
"public_security": "setting:seo",
|
||||
"seo_icon": "setting:seo",
|
||||
}
|
||||
|
||||
// getSettingRequiredPerm
|
||||
func getSettingRequiredPerm(key string) string {
|
||||
if perm, ok := SettingKeyPermissionMap[strings.TrimSuffix(key, "_configured")]; ok {
|
||||
return perm
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
"oneimg/backend/utils/settings"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetSettings is fail-closed: unmapped fields never leak to delegated users.
|
||||
func GetSettings(c *gin.Context) {
|
||||
user, exists := middlewares.GetCurrentUser(c)
|
||||
if !exists {
|
||||
c.JSON(401, result.Error(401, "未登录"))
|
||||
return
|
||||
}
|
||||
isAdmin := c.GetString("auth_method") != "api_token" && (user.ID == models.SuperAdminID || user.Role == models.RoleAdmin)
|
||||
if !isAdmin && !user.Permission.HasPermission("setting:list") {
|
||||
c.JSON(403, result.Error(403, "无权查看设置,需要权限 setting:list"))
|
||||
return
|
||||
}
|
||||
var req GetSettingsRequest
|
||||
if c.Request.Method == http.MethodPost && c.Request.ContentLength != 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, result.Error(400, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
req.Keys = c.QueryArray("keys")
|
||||
}
|
||||
settingModel, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取设置失败"))
|
||||
return
|
||||
}
|
||||
response := secureconfig.SanitizeSettingsForResponse(settingModel)
|
||||
addConfigurableSettings(response, settingModel)
|
||||
if url, err := oidcCallbackURL(settingModel); err == nil {
|
||||
response["oidc_redirect_url_effective"] = url
|
||||
}
|
||||
if url, err := casCallbackURL(settingModel); err == nil {
|
||||
response["cas_service_url_effective"] = url
|
||||
}
|
||||
var tokens []models.APICredential
|
||||
if isAdmin || user.Permission.HasPermission("setting:api") {
|
||||
query := database.GetDB().DB.Where("revoked = ?", false)
|
||||
if !isAdmin {
|
||||
query = query.Where("owner_id = ?", user.ID)
|
||||
}
|
||||
if query.Order("created_at desc").Limit(10).Find(&tokens).Error == nil {
|
||||
response["api_credentials"] = tokens
|
||||
for _, token := range tokens {
|
||||
if time.Now().Before(token.ExpiresAt) {
|
||||
response["api_token_configured"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered := filterSettings(response, req.Keys)
|
||||
permissions := []string{}
|
||||
if isAdmin {
|
||||
permissions = []string{"setting:upload", "setting:image", "setting:security", "setting:notification", "setting:api", "setting:seo"}
|
||||
} else {
|
||||
filterDelegatedSettings(filtered, user.Permission)
|
||||
for _, code := range user.Permission.Codes {
|
||||
if strings.HasPrefix(code, "setting:") {
|
||||
permissions = append(permissions, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
metadata := configurableFieldMetadata()
|
||||
if !isAdmin {
|
||||
for key, field := range metadata {
|
||||
if !user.Permission.HasPermission(field.Permission) {
|
||||
delete(metadata, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "message": "ok", "data": filtered, "setting_permissions": permissions, "setting_field_metadata": metadata})
|
||||
}
|
||||
|
||||
// GetLoginSettings 获取登录页公开配置。
|
||||
func GetLoginSettings(c *gin.Context) {
|
||||
settingModel, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取设置失败"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, result.Success("ok",
|
||||
map[string]any{
|
||||
"verify_method": effectiveVerifyMethodWithFallback(settingModel),
|
||||
"pow_script_url": effectivePowScriptURL(settingModel),
|
||||
"pow_widget_url": effectivePowWidgetURL(settingModel),
|
||||
"pow_verify": settingModel.PowVerify,
|
||||
"pow_local_fallback": settingModel.PowLocalFallback,
|
||||
"max_upload_files": publicLimitSettings(settingModel)["max_upload_files"],
|
||||
"tag_max_length": publicLimitSettings(settingModel)["tag_max_length"],
|
||||
"random_image_limit": publicLimitSettings(settingModel)["random_image_limit"],
|
||||
"turnstile_site_key": settingModel.TurnstileSiteKey,
|
||||
"tourist": settingModel.Tourist,
|
||||
"start_register": settingModel.StartRegister,
|
||||
"oidc_enabled": oidcSettingsReady(settingModel),
|
||||
"oidc_display_name": externalLoginDisplayName(settingModel.OIDCDisplayName, "OIDC 登录"),
|
||||
"cas_enabled": casSettingsReady(settingModel),
|
||||
"cas_display_name": externalLoginDisplayName(settingModel.CASDisplayName, "CAS 登录"),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// GetSEOSettings 获取站点 SEO 公开配置。
|
||||
func GetSEOSettings(c *gin.Context) {
|
||||
settingModel, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取设置失败"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, result.Success("ok",
|
||||
map[string]any{
|
||||
"seo_title": settingModel.SEOTitle,
|
||||
"seo_description": settingModel.SEODescription,
|
||||
"seo_keywords": settingModel.SEOKeywords,
|
||||
"seo_icp": settingModel.SEOICP,
|
||||
"public_security": settingModel.PublicSecurity,
|
||||
"seo_icon": settingModel.SEOicon,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func settingDisabledByPublicDomain(key string) bool {
|
||||
switch key {
|
||||
case "watermark_enable",
|
||||
"watermark_text",
|
||||
"watermark_size",
|
||||
"watermark_color",
|
||||
"watermark_opac",
|
||||
"watermark_pos",
|
||||
"referer_white_enable",
|
||||
"referer_white_list":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func getBucketTypeByID(id int) (string, error) {
|
||||
db := database.GetDB().DB
|
||||
var bucket models.Buckets
|
||||
if err := db.First(&bucket, id).Error; err != nil {
|
||||
return "", fmt.Errorf("存储桶不存在")
|
||||
}
|
||||
if bucket.Disabled {
|
||||
return "", fmt.Errorf("存储源已停用,请先启用")
|
||||
}
|
||||
return bucket.Type, nil
|
||||
}
|
||||
|
||||
func settingValueToInt(value any) (int, error) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, nil
|
||||
case int64:
|
||||
return int(v), nil
|
||||
case float64:
|
||||
return int(v), nil
|
||||
case string:
|
||||
num64, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(num64), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("类型错误:%T", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"log"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
"oneimg/backend/utils/settings"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
func UpdateSettings(c *gin.Context) {
|
||||
if c.GetString("auth_method") == "api_token" {
|
||||
c.JSON(403, result.Error(403, "API 凭据不能修改系统设置"))
|
||||
return
|
||||
}
|
||||
var req UpdateSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, result.Error(400, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.Key == "regenerate" {
|
||||
RegenerateAPIToken(c)
|
||||
return
|
||||
}
|
||||
if req.Key == "renew_session" {
|
||||
RenewSession(c)
|
||||
return
|
||||
}
|
||||
if getSettingRequiredPerm(req.Key) == "" {
|
||||
c.JSON(400, result.Error(400, "未知的设置项"))
|
||||
return
|
||||
}
|
||||
if req.Value == nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "设置值不能为空"))
|
||||
return
|
||||
}
|
||||
user, exists := middlewares.GetCurrentUser(c)
|
||||
if !exists {
|
||||
c.JSON(401, result.Error(401, "未登录"))
|
||||
return
|
||||
}
|
||||
if user.ID != models.SuperAdminID {
|
||||
requiredPerm := getSettingRequiredPerm(req.Key)
|
||||
if requiredPerm == "" {
|
||||
c.JSON(400, result.Error(400, "未知的设置项"))
|
||||
return
|
||||
}
|
||||
requiredPermName := models.GetPermissionName(requiredPerm)
|
||||
if !user.Permission.HasPermission(requiredPerm) {
|
||||
c.JSON(403, result.Error(403, "无权修改该设置项,需要权限: "+requiredPermName))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 查询是否有该设置项
|
||||
settingModel, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取设置失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验设置数据
|
||||
if err := validateSettingData(req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
fieldName, fieldType, err := findSettingsField(reflect.TypeOf(settingModel), req.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if secureconfig.IsSettingsSensitiveKey(req.Key) {
|
||||
if err := updateSensitiveSettingsField(&settingModel, req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
} else if err := updateSettingsField(&settingModel, req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新设置项
|
||||
db := database.GetDB().DB
|
||||
|
||||
updateColumn, updateValue, err := buildSettingsUpdate(req.Key, req.Value, fieldName, fieldType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if updateValue == nil && secureconfig.IsSettingsSensitiveKey(req.Key) {
|
||||
c.JSON(200, result.Success("无需更新", nil))
|
||||
return
|
||||
}
|
||||
|
||||
if err := ensureSettingsColumn(db, updateColumn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "数据库字段兼容处理失败"))
|
||||
log.Printf("设置 %s 更新失败(%T)", req.Key, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if req.Key == "api_token" {
|
||||
var state models.AuthState
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&state, 1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.APICredential{}).Where("revoked = ?", false).Update("revoked", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&settingModel).Updates(map[string]any{"api_token": "", "api_token_hash": updateValue}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingModel.APIToken = ""
|
||||
settingModel.APITokenHash = updateValue.(string)
|
||||
// An explicit settings edit is a rotation, not a lazy extension of expiry.
|
||||
if err := tx.Where("id = ?", authsecurity.LegacyTokenID(settingModel)).Delete(&models.APICredential{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return authsecurity.EnrollLegacyToken(tx, settingModel)
|
||||
}
|
||||
return tx.Model(&settingModel).Update(updateColumn, updateValue).Error
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Error(500, "更新失败"))
|
||||
log.Printf("设置 %s 更新失败(%T)", req.Key, err)
|
||||
return
|
||||
}
|
||||
|
||||
settings.Invalidate()
|
||||
c.JSON(200, result.Success("更新成功", nil))
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/publicurl"
|
||||
"oneimg/backend/utils/securestorage"
|
||||
"oneimg/backend/utils/settings"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var hexColorRegex = regexp.MustCompile(`^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
||||
|
||||
func validateSettingData(key string, value any) error {
|
||||
if handled, err := validateConfigurableSetting(key, value); handled {
|
||||
return err
|
||||
}
|
||||
if settingDisabledByPublicDomain(key) {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if publicurl.HasDomain(setting) {
|
||||
return fmt.Errorf("已配置图片直链域名,该设置不会生效,请先清空图片直链域名")
|
||||
}
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "oidc_issuer":
|
||||
_, err := normalizeOIDCIssuer(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "oidc_redirect_url":
|
||||
_, err := normalizeOIDCCallbackURL(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "cas_service_url":
|
||||
_, err := normalizeCASCallbackURL(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "cas_server_url":
|
||||
_, err := normalizeCASServerURL(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "oidc_scopes":
|
||||
_, err := normalizeOIDCScopes(fmt.Sprintf("%v", value))
|
||||
return err
|
||||
case "oidc_username_claim":
|
||||
claim := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if claim == "" || !oidcClaimNameRegex.MatchString(claim) {
|
||||
return fmt.Errorf("OIDC 用户名 Claim 格式不正确")
|
||||
}
|
||||
case "oidc_client_id":
|
||||
if len(strings.TrimSpace(fmt.Sprintf("%v", value))) > 512 {
|
||||
return fmt.Errorf("OIDC Client ID 过长")
|
||||
}
|
||||
case "oidc_client_secret":
|
||||
if len(strings.TrimSpace(fmt.Sprintf("%v", value))) > 4096 {
|
||||
return fmt.Errorf("OIDC Client Secret 过长")
|
||||
}
|
||||
case "oidc_display_name", "cas_display_name":
|
||||
name := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if len([]rune(name)) > 40 {
|
||||
return fmt.Errorf("登录按钮名称不能超过40个字符")
|
||||
}
|
||||
case "oidc_super_admin_username", "cas_super_admin_username":
|
||||
if len([]rune(strings.TrimSpace(fmt.Sprintf("%v", value)))) > 255 {
|
||||
return fmt.Errorf("超级管理员映射用户名不能超过255个字符")
|
||||
}
|
||||
case "oidc_enable":
|
||||
enabled, err := convertValueToTargetType(key, value, reflect.TypeOf(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled.(bool) {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil || !oidcSettingsComplete(setting) {
|
||||
return fmt.Errorf("请先完整配置 OIDC Issuer、Client ID、Client Secret 和回调地址")
|
||||
}
|
||||
}
|
||||
case "cas_enable":
|
||||
enabled, err := convertValueToTargetType(key, value, reflect.TypeOf(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled.(bool) {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil || !casSettingsComplete(setting) {
|
||||
return fmt.Errorf("请先完整配置 CAS Server URL 和回调地址")
|
||||
}
|
||||
}
|
||||
case "verify_method":
|
||||
method := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
switch method {
|
||||
case models.VerifyMethodNone, models.VerifyMethodPOW, models.VerifyMethodTurnstile, models.VerifyMethodCappow:
|
||||
default:
|
||||
return fmt.Errorf("验证方式不合法,可选:无验证、在线POW、Turnstile、cap-pow")
|
||||
}
|
||||
// 允许先切换方式、后配置密钥:若 Turnstile 密钥未配置,登录/注册时
|
||||
// 会提示「Turnstile 验证尚未配置」,避免配置死锁。
|
||||
case "turnstile_site_key":
|
||||
siteKey := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if siteKey == "" {
|
||||
return nil // 允许清空
|
||||
}
|
||||
// 保存前通过 Cloudflare 管理 API 校验公钥真实存在(需先配置 API Token 与账号 ID)
|
||||
setting, err := settings.GetSettingsWithSecrets("cloudflare_api_token")
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if err := validateTurnstileSiteKeyAPI(siteKey, setting.CloudflareAPIToken, setting.CloudflareAccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "turnstile_secret_key":
|
||||
// 留空表示不修改(沿用已配置密钥),跳过校验
|
||||
if strings.TrimSpace(fmt.Sprintf("%v", value)) == "" {
|
||||
return nil
|
||||
}
|
||||
// 保存前发起模拟验证请求,确认密钥被 Cloudflare 识别
|
||||
if err := probeTurnstileSecret(fmt.Sprintf("%v", value)); err != nil {
|
||||
return err
|
||||
}
|
||||
case "cappow_difficulty":
|
||||
diff, err := settingValueToInt(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cap-pow 难度必须为整数")
|
||||
}
|
||||
if diff < 1 || diff > 8 {
|
||||
return fmt.Errorf("cap-pow 难度必须在 1-8 之间")
|
||||
}
|
||||
case "encrypted_storage":
|
||||
enabled, err := convertValueToTargetType(key, value, reflect.TypeOf(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled.(bool) {
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if strings.TrimSpace(setting.PublicImageDomain) != "" {
|
||||
return fmt.Errorf("加密存储要求所有图片经程序解密,请先清空图片直链域名")
|
||||
}
|
||||
if _, err := securestorage.Encrypt(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "public_image_domain":
|
||||
domain, err := publicurl.NormalizeDomain(fmt.Sprintf("%v", value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if setting.EncryptedStorage {
|
||||
return fmt.Errorf("加密存储已开启,图片必须经程序解密,不能配置直链域名")
|
||||
}
|
||||
bucketType, err := getBucketTypeByID(setting.DefaultStorage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !publicurl.SupportsStorage(bucketType) {
|
||||
return fmt.Errorf("当前默认存储不支持图片直链域名,请先切换到 S3 或 R2 存储")
|
||||
}
|
||||
|
||||
case "watermark_text":
|
||||
// 1. 水印文字长度校验(兼容字符串类型)
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("水印文字必须是字符串类型,实际类型:%T", value)
|
||||
}
|
||||
if len(text) > 20 {
|
||||
return fmt.Errorf("水印文字长度不能超过20个字符(当前:%d)", len(text))
|
||||
}
|
||||
|
||||
case "watermark_size":
|
||||
// 2. 水印字体大小校验
|
||||
var size int
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
size = v
|
||||
case string:
|
||||
// 字符串转int
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return errors.New("水印字体大小不能为空")
|
||||
}
|
||||
num, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("水印字体大小必须是整数(当前值:%s)", v)
|
||||
}
|
||||
size = num
|
||||
default:
|
||||
return fmt.Errorf("水印字体大小必须是整数或数字字符串,实际类型:%T", value)
|
||||
}
|
||||
// 范围校验
|
||||
if size < 1 || size > 100 {
|
||||
return fmt.Errorf("水印字体大小必须在1-100之间(当前:%d)", size)
|
||||
}
|
||||
|
||||
case "watermark_color":
|
||||
// 3. 水印颜色校验(防空 + 十六进制格式)
|
||||
color, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("水印颜色必须是字符串类型,实际类型:%T", value)
|
||||
}
|
||||
color = strings.TrimSpace(color)
|
||||
if color == "" {
|
||||
return errors.New("水印字体颜色不能为空")
|
||||
}
|
||||
if !hexColorRegex.MatchString(color) {
|
||||
return fmt.Errorf("水印颜色格式错误,请使用十六进制颜色码,当前值:%s", color)
|
||||
}
|
||||
|
||||
case "watermark_opac":
|
||||
// 4. 水印透明度校验(兼容字符串/float64/int,转为float64后校验0-1)
|
||||
var opac float64
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
opac = v
|
||||
case int:
|
||||
opac = float64(v) // int转float64(如 1 → 1.0)
|
||||
case string:
|
||||
// 字符串转float64
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return errors.New("水印透明度不能为空")
|
||||
}
|
||||
num, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("水印透明度必须是数字(当前值:%s)", v)
|
||||
}
|
||||
opac = num
|
||||
default:
|
||||
return fmt.Errorf("水印透明度必须是数字或数字字符串,实际类型:%T", value)
|
||||
}
|
||||
// 范围校验(0.0-1.0)
|
||||
if opac < 0.0 || opac > 1.0 {
|
||||
return fmt.Errorf("水印透明度必须在0-1之间(当前:%.2f)", opac)
|
||||
}
|
||||
|
||||
case "watermark_pos":
|
||||
// 5. 水印位置校验(防空 + 合法值)
|
||||
pos, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("水印位置必须是字符串类型,实际类型:%T", value)
|
||||
}
|
||||
pos = strings.TrimSpace(pos)
|
||||
if pos == "" {
|
||||
return errors.New("水印位置不能为空")
|
||||
}
|
||||
// 合法位置集合
|
||||
validPos := map[string]bool{
|
||||
"top-left": true,
|
||||
"top-right": true,
|
||||
"bottom-left": true,
|
||||
"bottom-right": true,
|
||||
"center": true,
|
||||
}
|
||||
if !validPos[pos] {
|
||||
return fmt.Errorf("水印位置参数不合法")
|
||||
}
|
||||
case "guest_storage":
|
||||
return validateGuestStorage(value)
|
||||
case "default_storage":
|
||||
// 检查存储配置是否存在
|
||||
id, err := settingValueToInt(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", "解析失败: "+err.Error())
|
||||
}
|
||||
|
||||
bucketType, err := getBucketTypeByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取系统配置失败")
|
||||
}
|
||||
if publicurl.HasDomain(setting) && !publicurl.SupportsStorage(bucketType) {
|
||||
return fmt.Errorf("图片直链域名仅支持 S3/R2 存储,请先清空该配置")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/powverify"
|
||||
"oneimg/backend/utils/publicurl"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func findSettingsField(typ reflect.Type, key string) (string, reflect.Type, error) {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == key || field.Name == key {
|
||||
return field.Name, field.Type, nil
|
||||
}
|
||||
}
|
||||
return "", nil, fmt.Errorf("设置项 %s 不存在", key)
|
||||
}
|
||||
|
||||
func updateSensitiveSettingsField(settings *models.Settings, key string, value any) error {
|
||||
stringValue := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
switch key {
|
||||
case "api_token":
|
||||
settings.APIToken = ""
|
||||
settings.APITokenHash = stringValue
|
||||
return nil
|
||||
case "tg_bot_token":
|
||||
settings.TGBotToken = stringValue
|
||||
return nil
|
||||
case "oidc_client_secret":
|
||||
settings.OIDCClientSecret = stringValue
|
||||
return nil
|
||||
case "turnstile_secret_key":
|
||||
settings.TurnstileSecret = stringValue
|
||||
return nil
|
||||
case "cloudflare_api_token":
|
||||
settings.CloudflareAPIToken = stringValue
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("设置项 %s 不支持敏感更新", key)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSettingsUpdate(key string, value any, fieldName string, fieldType reflect.Type) (string, any, error) {
|
||||
normalizedValue, err := secureconfig.NormalizeSettingValue(key, value)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "max_upload_files", "tag_max_length", "random_image_limit", "pow_verify_timeout_seconds":
|
||||
n, err := strictSettingInt(value)
|
||||
return key, n, err
|
||||
case "pow_verify_url", "pow_script_url", "pow_widget_url":
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("POW 验证地址必须为字符串")
|
||||
}
|
||||
normalized, err := powverify.NormalizeURL(text)
|
||||
return key, normalized, err
|
||||
case "api_token":
|
||||
return "api_token_hash", normalizedValue, nil
|
||||
case "tg_bot_token", "oidc_client_secret", "turnstile_secret_key", "cloudflare_api_token":
|
||||
return key, normalizedValue, nil
|
||||
case "public_image_domain":
|
||||
domain, err := publicurl.NormalizeDomain(fmt.Sprintf("%v", value))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "public_image_domain", domain, nil
|
||||
case "oidc_issuer":
|
||||
normalized, err := normalizeOIDCIssuer(fmt.Sprintf("%v", value))
|
||||
return "oidc_issuer", normalized, err
|
||||
case "oidc_redirect_url":
|
||||
normalized, err := normalizeOIDCCallbackURL(fmt.Sprintf("%v", value))
|
||||
return "oidc_redirect_url", normalized, err
|
||||
case "cas_server_url":
|
||||
normalized, err := normalizeCASServerURL(fmt.Sprintf("%v", value))
|
||||
return "cas_server_url", normalized, err
|
||||
case "cas_service_url":
|
||||
normalized, err := normalizeCASCallbackURL(fmt.Sprintf("%v", value))
|
||||
return "cas_service_url", normalized, err
|
||||
case "oidc_scopes":
|
||||
normalized, err := normalizeOIDCScopes(fmt.Sprintf("%v", value))
|
||||
return "oidc_scopes", normalized, err
|
||||
case "oidc_client_id", "oidc_username_claim", "oidc_display_name", "oidc_super_admin_username", "cas_display_name", "cas_super_admin_username":
|
||||
return getSettingsColumnName(fieldName), strings.TrimSpace(fmt.Sprintf("%v", value)), nil
|
||||
default:
|
||||
convertedValue, convertErr := convertValueToTargetType(key, value, fieldType)
|
||||
if convertErr != nil {
|
||||
return "", nil, convertErr
|
||||
}
|
||||
return getSettingsColumnName(fieldName), convertedValue, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getSettingsColumnName(fieldName string) string {
|
||||
settingsType := reflect.TypeOf(models.Settings{})
|
||||
if field, ok := settingsType.FieldByName(fieldName); ok {
|
||||
gormTag := field.Tag.Get("gorm")
|
||||
for _, part := range strings.Split(gormTag, ";") {
|
||||
if strings.HasPrefix(part, "column:") {
|
||||
return strings.TrimPrefix(part, "column:")
|
||||
}
|
||||
}
|
||||
}
|
||||
return fieldName
|
||||
}
|
||||
|
||||
func ensureSettingsColumn(db *gorm.DB, columnName string) error {
|
||||
fieldName, err := getSettingsFieldNameByColumn(columnName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Settings{}, columnName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[数据库兼容] settings.%s 字段不存在,尝试创建", columnName)
|
||||
if err := db.Migrator().AddColumn(&models.Settings{}, fieldName); err != nil {
|
||||
return fmt.Errorf("创建 settings.%s 字段失败: %w", columnName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSettingsFieldNameByColumn(columnName string) (string, error) {
|
||||
settingsType := reflect.TypeOf(models.Settings{})
|
||||
for i := 0; i < settingsType.NumField(); i++ {
|
||||
field := settingsType.Field(i)
|
||||
if getSettingsColumnName(field.Name) == columnName || field.Name == columnName {
|
||||
return field.Name, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("设置字段 %s 不存在", columnName)
|
||||
}
|
||||
|
||||
func updateSettingsField(settings *models.Settings, key string, value any) error {
|
||||
// 获取结构体反射值(指针解引用)
|
||||
val := reflect.ValueOf(settings).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
// 1. 遍历结构体字段,匹配JSON Tag或字段名
|
||||
var targetField reflect.Value
|
||||
var fieldType reflect.Type
|
||||
found := false
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
// 优先匹配JSON Tag(如 json:"tourist")
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == key || field.Name == key {
|
||||
targetField = val.Field(i)
|
||||
fieldType = field.Type
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 校验字段是否存在
|
||||
if !found {
|
||||
return fmt.Errorf("设置项 %s 不存在", key)
|
||||
}
|
||||
|
||||
// 2. 校验字段是否可修改(必须是导出字段)
|
||||
if !targetField.CanSet() {
|
||||
return fmt.Errorf("设置项 %s 不可修改", key)
|
||||
}
|
||||
|
||||
// 3. 处理nil值(避免panic)
|
||||
if value == nil {
|
||||
return fmt.Errorf("设置项 %s 的值不能为空", key)
|
||||
}
|
||||
|
||||
// 4. 转换value类型为字段实际类型
|
||||
convertedValue, err := convertValueToTargetType(key, value, fieldType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
valueVal := reflect.ValueOf(convertedValue)
|
||||
|
||||
// 5. 设置字段值
|
||||
targetField.Set(valueVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertValueToTargetType(key string, value any, targetType reflect.Type) (any, error) {
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("设置项 %s 的值不能为空", key)
|
||||
}
|
||||
if targetType.Kind() == reflect.Int {
|
||||
return strictSettingInt(value)
|
||||
}
|
||||
valueVal := reflect.ValueOf(value)
|
||||
valueType := valueVal.Type()
|
||||
|
||||
// 类型已匹配,直接返回
|
||||
if valueType == targetType {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// 场景1:反射支持直接转换(如 int→float64、bool→int 等)
|
||||
if valueType.ConvertibleTo(targetType) {
|
||||
return valueVal.Convert(targetType).Interface(), nil
|
||||
}
|
||||
|
||||
// 场景2:反射不支持直接转换,手动处理常见类型解析
|
||||
switch targetType.Kind() {
|
||||
// 处理 string → float64(解决watermark_opac的核心问题)
|
||||
case reflect.Float64:
|
||||
if valueType.Kind() == reflect.String {
|
||||
strVal := valueVal.String()
|
||||
floatVal, err := strconv.ParseFloat(strVal, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("设置项 %s 类型转换失败,期望 float64,实际 string(值:%s),错误:%v",
|
||||
key, strVal, err)
|
||||
}
|
||||
return floatVal, nil
|
||||
}
|
||||
|
||||
// 处理 string → int/int64
|
||||
case reflect.Int:
|
||||
if valueType.Kind() == reflect.String {
|
||||
strVal := valueVal.String()
|
||||
intVal, err := strconv.Atoi(strVal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("设置项 %s 类型转换失败,期望 int,实际 string(值:%s),错误:%v",
|
||||
key, strVal, err)
|
||||
}
|
||||
return intVal, nil
|
||||
}
|
||||
case reflect.Int64:
|
||||
if valueType.Kind() == reflect.String {
|
||||
strVal := valueVal.String()
|
||||
int64Val, err := strconv.ParseInt(strVal, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("设置项 %s 类型转换失败,期望 int64,实际 string(值:%s),错误:%v",
|
||||
key, strVal, err)
|
||||
}
|
||||
return int64Val, nil
|
||||
}
|
||||
|
||||
// 处理 string → bool
|
||||
case reflect.Bool:
|
||||
if valueType.Kind() == reflect.String {
|
||||
strVal := valueVal.String()
|
||||
boolVal, err := strconv.ParseBool(strVal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("设置项 %s 类型转换失败,期望 bool,实际 string(值:%s),错误:%v",
|
||||
key, strVal, err)
|
||||
}
|
||||
return boolVal, nil
|
||||
}
|
||||
case reflect.String:
|
||||
// 所有基础类型都可以转为string
|
||||
return fmt.Sprintf("%v", value), nil
|
||||
}
|
||||
|
||||
// 不支持的转换类型
|
||||
return nil, fmt.Errorf("设置项 %s 类型不匹配,期望 %s,实际 %T",
|
||||
key, targetType, value)
|
||||
}
|
||||
+154
-278
@@ -1,26 +1,23 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StatsResponse 统计响应结构
|
||||
type StatsResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// DashboardStats 仪表板统计数据
|
||||
type DashboardStats struct {
|
||||
TotalImages int64 `json:"total_images"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
@@ -31,320 +28,199 @@ type DashboardStats struct {
|
||||
FormatStats []FormatStatsItem `json:"format_stats"`
|
||||
SizeDistribution []SizeDistributionItem `json:"size_distribution"`
|
||||
}
|
||||
|
||||
// UploadTrendItem 上传趋势项
|
||||
type UploadTrendItem struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// FormatStatsItem 格式统计项
|
||||
type FormatStatsItem struct {
|
||||
Format string `json:"format"`
|
||||
Count int64 `json:"count"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// SizeDistributionItem 大小分布项
|
||||
type SizeDistributionItem struct {
|
||||
Range string `json:"range"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// scopeStatsImages 按角色限制统计范围:管理员看全局,用户/游客只看自己。
|
||||
func scopeStatsImages(db *gorm.DB, roleID, userID int, userUUID string) *gorm.DB {
|
||||
// Only trusted server-assigned IDs establish ownership, including negative
|
||||
// persistent guest IDs. A public username/UUID is never an ownership proof.
|
||||
func scopeStatsImages(db *gorm.DB, roleID, userID int, _ string) *gorm.DB {
|
||||
q := db.Model(&models.Image{})
|
||||
if roleID == models.RoleAdmin {
|
||||
if roleID == models.RoleAdmin && userID > 0 {
|
||||
return q
|
||||
}
|
||||
if roleID == models.RoleGuest {
|
||||
return q.Where("uuid = ?", userUUID)
|
||||
if (roleID == models.RoleUser && userID > 0) || (roleID == models.RoleGuest && userID != 0) {
|
||||
return q.Where("user_id = ?", userID)
|
||||
}
|
||||
return q.Where("user_id = ?", userID)
|
||||
return q.Where("1 = 0")
|
||||
}
|
||||
|
||||
// createdAtPrefix 返回“将 created_at 按年/月截断”的数据库方言兼容表达式。
|
||||
// SQLite 用 strftime,MySQL 用 DATE_FORMAT,PostgreSQL 用 TO_CHAR。
|
||||
func createdAtPrefix(db *gorm.DB, part string) string {
|
||||
switch db.Dialector.Name() {
|
||||
case "mysql":
|
||||
if part == "month" {
|
||||
return "DATE_FORMAT(created_at, '%Y-%m')"
|
||||
}
|
||||
return "DATE_FORMAT(created_at, '%Y')"
|
||||
case "postgres":
|
||||
if part == "month" {
|
||||
return "TO_CHAR(created_at, 'YYYY-MM')"
|
||||
}
|
||||
return "TO_CHAR(created_at, 'YYYY')"
|
||||
default: // sqlite
|
||||
if part == "month" {
|
||||
return "strftime('%Y-%m', created_at)"
|
||||
}
|
||||
return "strftime('%Y', created_at)"
|
||||
}
|
||||
var statsSizeRanges = []struct {
|
||||
name string
|
||||
min, max int64
|
||||
}{
|
||||
{"< 100KB", 0, 100 * 1024}, {"100KB - 500KB", 100 * 1024, 500 * 1024}, {"500KB - 1MB", 500 * 1024, 1024 * 1024},
|
||||
{"1MB - 5MB", 1024 * 1024, 5 * 1024 * 1024}, {"5MB - 10MB", 5 * 1024 * 1024, 10 * 1024 * 1024}, {"> 10MB", 10 * 1024 * 1024, 0},
|
||||
}
|
||||
|
||||
// GetDashboardStats 获取仪表板统计数据
|
||||
func dayStart(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// Dashboard summary plus six size bins in one portable conditional aggregation.
|
||||
func loadDashboardStats(db *gorm.DB, role, userID int, now time.Time) (DashboardStats, error) {
|
||||
stats := DashboardStats{RecentImages: []models.Image{}, FormatStats: []FormatStatsItem{}}
|
||||
today := dayStart(now)
|
||||
month := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
selectSQL := "COUNT(*) AS total_images, COALESCE(SUM(file_size),0) AS total_size, COALESCE(SUM(CASE WHEN created_at >= ? AND created_at < ? THEN 1 ELSE 0 END),0) AS today_uploads, COALESCE(SUM(CASE WHEN created_at >= ? AND created_at < ? THEN 1 ELSE 0 END),0) AS month_uploads"
|
||||
args := []interface{}{today, today.AddDate(0, 0, 1), month, month.AddDate(0, 1, 0)}
|
||||
for i, r := range statsSizeRanges {
|
||||
expr := "file_size >= ?"
|
||||
args = append(args, r.min)
|
||||
if r.max > 0 {
|
||||
expr += " AND file_size < ?"
|
||||
args = append(args, r.max)
|
||||
}
|
||||
selectSQL += fmt.Sprintf(", COALESCE(SUM(CASE WHEN %s THEN 1 ELSE 0 END),0) AS bin_%d", expr, i)
|
||||
}
|
||||
var summary struct {
|
||||
TotalImages, TotalSize, TodayUploads, MonthUploads int64
|
||||
Bin0 int64 `gorm:"column:bin_0"`
|
||||
Bin1 int64 `gorm:"column:bin_1"`
|
||||
Bin2 int64 `gorm:"column:bin_2"`
|
||||
Bin3 int64 `gorm:"column:bin_3"`
|
||||
Bin4 int64 `gorm:"column:bin_4"`
|
||||
Bin5 int64 `gorm:"column:bin_5"`
|
||||
}
|
||||
if err := scopeStatsImages(db, role, userID, "").Select(selectSQL, args...).Scan(&summary).Error; err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.TotalImages, stats.TotalSize, stats.TodayUploads, stats.MonthUploads = summary.TotalImages, summary.TotalSize, summary.TodayUploads, summary.MonthUploads
|
||||
bins := []int64{summary.Bin0, summary.Bin1, summary.Bin2, summary.Bin3, summary.Bin4, summary.Bin5}
|
||||
for i, r := range statsSizeRanges {
|
||||
stats.SizeDistribution = append(stats.SizeDistribution, SizeDistributionItem{Range: r.name, Count: bins[i]})
|
||||
}
|
||||
if err := scopeStatsImages(db, role, userID, "").Order("created_at DESC").Order("id DESC").Limit(10).Find(&stats.RecentImages).Error; err != nil {
|
||||
return stats, err
|
||||
}
|
||||
trend, err := loadPeriodStats(db, "dashboard", role, userID, now)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.UploadTrend = trend
|
||||
if err := scopeStatsImages(db, role, userID, "").Select("COALESCE(mime_type,'') AS format, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size").Group("mime_type").Order("mime_type ASC").Scan(&stats.FormatStats).Error; err != nil {
|
||||
return stats, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func statsError(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, StatsResponse{Code: 500, Message: message, Success: false})
|
||||
}
|
||||
func GetDashboardStats(c *gin.Context) {
|
||||
db := database.GetDB().DB
|
||||
|
||||
var stats DashboardStats
|
||||
|
||||
roleID := c.GetInt("user_role")
|
||||
userID := c.GetInt("user_id")
|
||||
userUUID := GetUUID(c)
|
||||
|
||||
// 每次重新 scope,避免 GORM 条件叠加
|
||||
scopeStatsImages(db, roleID, userID, userUUID).Count(&stats.TotalImages)
|
||||
|
||||
var totalSize struct {
|
||||
Total int64
|
||||
db := database.GetDB()
|
||||
if db == nil || db.DB == nil {
|
||||
statsError(c, "数据库不可用")
|
||||
return
|
||||
}
|
||||
stats, err := loadDashboardStats(db.DB.WithContext(c.Request.Context()), c.GetInt("user_role"), c.GetInt("user_id"), time.Now())
|
||||
if err != nil {
|
||||
statsError(c, "获取统计数据失败")
|
||||
return
|
||||
}
|
||||
scopeStatsImages(db, roleID, userID, userUUID).Select("COALESCE(SUM(file_size), 0) as total").Scan(&totalSize)
|
||||
stats.TotalSize = totalSize.Total
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
scopeStatsImages(db, roleID, userID, userUUID).Where("DATE(created_at) = ?", today).Count(&stats.TodayUploads)
|
||||
|
||||
now := time.Now()
|
||||
startTime := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
endTime := startTime.AddDate(0, 1, 0)
|
||||
scopeStatsImages(db, roleID, userID, userUUID).Where("created_at >= ? AND created_at < ?", startTime, endTime).Count(&stats.MonthUploads)
|
||||
|
||||
scopeStatsImages(db, roleID, userID, userUUID).Order("created_at DESC").Limit(10).Find(&stats.RecentImages)
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, StatsResponse{
|
||||
Code: 500,
|
||||
Message: "获取系统配置失败",
|
||||
Success: false,
|
||||
})
|
||||
statsError(c, "获取系统配置失败")
|
||||
return
|
||||
}
|
||||
for i := range stats.RecentImages {
|
||||
rewriteImageURLs(setting, &stats.RecentImages[i])
|
||||
}
|
||||
|
||||
// 获取最近7天的上传趋势
|
||||
stats.UploadTrend = getUploadTrend(db, 7, roleID, userID, userUUID)
|
||||
|
||||
// 获取格式统计
|
||||
stats.FormatStats = getFormatStats(db, roleID, userID, userUUID)
|
||||
|
||||
// 获取大小分布
|
||||
stats.SizeDistribution = getSizeDistribution(db, roleID, userID, userUUID)
|
||||
|
||||
c.JSON(http.StatusOK, StatsResponse{
|
||||
Code: 200,
|
||||
Message: "获取统计数据成功",
|
||||
Success: true,
|
||||
Data: stats,
|
||||
})
|
||||
c.JSON(http.StatusOK, StatsResponse{Code: 200, Message: "获取统计数据成功", Success: true, Data: stats})
|
||||
}
|
||||
|
||||
// getUploadTrend 获取上传趋势
|
||||
func getUploadTrend(db *gorm.DB, days int, roleID, userID int, userUUID string) []UploadTrendItem {
|
||||
var trend []UploadTrendItem
|
||||
type statsPeriod struct {
|
||||
label string
|
||||
start, end time.Time
|
||||
}
|
||||
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
date := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
||||
|
||||
var count int64
|
||||
scopeStatsImages(db, roleID, userID, userUUID).Where("DATE(created_at) = ?", date).Count(&count)
|
||||
|
||||
trend = append(trend, UploadTrendItem{
|
||||
Date: date,
|
||||
Count: count,
|
||||
})
|
||||
func statsPeriods(period string, now time.Time) []statsPeriod {
|
||||
periods := []statsPeriod{}
|
||||
switch period {
|
||||
case "day", "dashboard":
|
||||
days := 30
|
||||
if period == "dashboard" {
|
||||
days = 7
|
||||
}
|
||||
today := dayStart(now)
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
start := today.AddDate(0, 0, -i)
|
||||
periods = append(periods, statsPeriod{start.Format("2006-01-02"), start, start.AddDate(0, 0, 1)})
|
||||
}
|
||||
case "week":
|
||||
// Monday start; Sunday belongs to the preceding Monday, not the next week.
|
||||
monday := dayStart(now).AddDate(0, 0, -(int(now.Weekday())+6)%7)
|
||||
for i := 11; i >= 0; i-- {
|
||||
start := monday.AddDate(0, 0, -i*7)
|
||||
periods = append(periods, statsPeriod{start.Format("2006-01-02"), start, start.AddDate(0, 0, 7)})
|
||||
}
|
||||
case "year":
|
||||
current := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||||
for i := 4; i >= 0; i-- {
|
||||
start := current.AddDate(-i, 0, 0)
|
||||
periods = append(periods, statsPeriod{start.Format("2006"), start, start.AddDate(1, 0, 0)})
|
||||
}
|
||||
default:
|
||||
current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
for i := 11; i >= 0; i-- {
|
||||
start := current.AddDate(0, -i, 0)
|
||||
periods = append(periods, statsPeriod{start.Format("2006-01"), start, start.AddDate(0, 1, 0)})
|
||||
}
|
||||
}
|
||||
|
||||
return trend
|
||||
return periods
|
||||
}
|
||||
|
||||
// getFormatStats 获取格式统计
|
||||
func getFormatStats(db *gorm.DB, roleID, userID int, userUUID string) []FormatStatsItem {
|
||||
var stats []FormatStatsItem
|
||||
|
||||
rows, err := scopeStatsImages(db, roleID, userID, userUUID).
|
||||
Select("mime_type as format, COUNT(*) as count, COALESCE(SUM(file_size), 0) as size").
|
||||
Group("mime_type").
|
||||
Rows()
|
||||
|
||||
// One bounded query for 7/30 days, 12 weeks/months or 5 years. Range predicates
|
||||
// preserve timestamp index use; no DATE(column) casts or query-per-period loops.
|
||||
func loadPeriodStats(db *gorm.DB, period string, role, userID int, now time.Time) ([]UploadTrendItem, error) {
|
||||
periods := statsPeriods(period, now)
|
||||
items := make([]UploadTrendItem, len(periods))
|
||||
expressions := []string{}
|
||||
args := []interface{}{}
|
||||
for i, p := range periods {
|
||||
items[i].Date = p.label
|
||||
expressions = append(expressions, fmt.Sprintf("COALESCE(SUM(CASE WHEN created_at >= ? AND created_at < ? THEN 1 ELSE 0 END),0) AS count_%d", i))
|
||||
args = append(args, p.start, p.end)
|
||||
}
|
||||
rows, err := scopeStatsImages(db, role, userID, "").Where("created_at >= ? AND created_at < ?", periods[0].start, periods[len(periods)-1].end).Select(strings.Join(expressions, ", "), args...).Rows()
|
||||
if err != nil {
|
||||
return stats
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var item FormatStatsItem
|
||||
rows.Scan(&item.Format, &item.Count, &item.Size)
|
||||
stats = append(stats, item)
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// getSizeDistribution 获取大小分布
|
||||
func getSizeDistribution(db *gorm.DB, roleID, userID int, userUUID string) []SizeDistributionItem {
|
||||
var distribution []SizeDistributionItem
|
||||
|
||||
// 定义大小范围
|
||||
ranges := []struct {
|
||||
name string
|
||||
min int64
|
||||
max int64
|
||||
}{
|
||||
{"< 100KB", 0, 100 * 1024},
|
||||
{"100KB - 500KB", 100 * 1024, 500 * 1024},
|
||||
{"500KB - 1MB", 500 * 1024, 1024 * 1024},
|
||||
{"1MB - 5MB", 1024 * 1024, 5 * 1024 * 1024},
|
||||
{"5MB - 10MB", 5 * 1024 * 1024, 10 * 1024 * 1024},
|
||||
{"> 10MB", 10 * 1024 * 1024, 0},
|
||||
}
|
||||
|
||||
for _, r := range ranges {
|
||||
var count int64
|
||||
query := scopeStatsImages(db, roleID, userID, userUUID)
|
||||
|
||||
if r.max == 0 {
|
||||
// 最后一个范围,只有最小值
|
||||
query = query.Where("file_size >= ?", r.min)
|
||||
} else {
|
||||
query = query.Where("file_size >= ? AND file_size < ?", r.min, r.max)
|
||||
if rows.Next() {
|
||||
dest := make([]interface{}, len(items))
|
||||
for i := range items {
|
||||
dest[i] = &items[i].Count
|
||||
}
|
||||
if err := rows.Scan(dest...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query.Count(&count)
|
||||
|
||||
distribution = append(distribution, SizeDistributionItem{
|
||||
Range: r.name,
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
|
||||
return distribution
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetImageStats 获取图片详细统计
|
||||
func GetImageStats(c *gin.Context) {
|
||||
db := database.GetDB().DB
|
||||
roleID := c.GetInt("user_role")
|
||||
userID := c.GetInt("user_id")
|
||||
userUUID := GetUUID(c)
|
||||
|
||||
// 获取查询参数
|
||||
period := c.DefaultQuery("period", "month") // day, week, month, year
|
||||
|
||||
var stats any
|
||||
|
||||
switch period {
|
||||
case "day":
|
||||
stats = getDailyStats(db, roleID, userID, userUUID)
|
||||
case "week":
|
||||
stats = getWeeklyStats(db, roleID, userID, userUUID)
|
||||
case "month":
|
||||
stats = getMonthlyStats(db, roleID, userID, userUUID)
|
||||
case "year":
|
||||
stats = getYearlyStats(db, roleID, userID, userUUID)
|
||||
default:
|
||||
stats = getMonthlyStats(db, roleID, userID, userUUID)
|
||||
db := database.GetDB()
|
||||
if db == nil || db.DB == nil {
|
||||
statsError(c, "数据库不可用")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, StatsResponse{
|
||||
Code: 200,
|
||||
Message: "获取图片统计成功",
|
||||
Success: true,
|
||||
Data: stats,
|
||||
})
|
||||
}
|
||||
|
||||
// getDailyStats 获取每日统计
|
||||
func getDailyStats(db *gorm.DB, roleID, userID int, userUUID string) []UploadTrendItem {
|
||||
var stats []UploadTrendItem
|
||||
|
||||
// 获取最近30天的数据
|
||||
for i := 29; i >= 0; i-- {
|
||||
date := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
||||
|
||||
var count int64
|
||||
scopeStatsImages(db, roleID, userID, userUUID).Where("DATE(created_at) = ?", date).Count(&count)
|
||||
|
||||
stats = append(stats, UploadTrendItem{
|
||||
Date: date,
|
||||
Count: count,
|
||||
})
|
||||
stats, err := loadPeriodStats(db.DB.WithContext(c.Request.Context()), c.DefaultQuery("period", "month"), c.GetInt("user_role"), c.GetInt("user_id"), time.Now())
|
||||
if err != nil {
|
||||
statsError(c, "获取图片统计失败")
|
||||
return
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// getWeeklyStats 获取每周统计
|
||||
func getWeeklyStats(db *gorm.DB, roleID, userID int, userUUID string) []UploadTrendItem {
|
||||
var stats []UploadTrendItem
|
||||
|
||||
// 获取最近12周的数据
|
||||
for i := 11; i >= 0; i-- {
|
||||
// 计算周的开始日期
|
||||
weekStart := time.Now().AddDate(0, 0, -i*7-int(time.Now().Weekday())+1)
|
||||
weekEnd := weekStart.AddDate(0, 0, 6)
|
||||
|
||||
var count int64
|
||||
scopeStatsImages(db, roleID, userID, userUUID).
|
||||
Where("created_at >= ? AND created_at <= ?",
|
||||
weekStart.Format("2006-01-02"),
|
||||
weekEnd.Format("2006-01-02 23:59:59")).
|
||||
Count(&count)
|
||||
|
||||
stats = append(stats, UploadTrendItem{
|
||||
Date: weekStart.Format("2006-01-02"),
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// getMonthlyStats 获取每月统计
|
||||
func getMonthlyStats(db *gorm.DB, roleID, userID int, userUUID string) []UploadTrendItem {
|
||||
var stats []UploadTrendItem
|
||||
|
||||
// 获取最近12个月的数据
|
||||
for i := 11; i >= 0; i-- {
|
||||
date := time.Now().AddDate(0, -i, 0)
|
||||
monthStr := date.Format("2006-01")
|
||||
|
||||
var count int64
|
||||
scopeStatsImages(db, roleID, userID, userUUID).
|
||||
Where(createdAtPrefix(db, "month")+" = ?", monthStr).
|
||||
Count(&count)
|
||||
|
||||
stats = append(stats, UploadTrendItem{
|
||||
Date: monthStr,
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// getYearlyStats 获取每年统计
|
||||
func getYearlyStats(db *gorm.DB, roleID, userID int, userUUID string) []UploadTrendItem {
|
||||
var stats []UploadTrendItem
|
||||
|
||||
// 获取最近5年的数据
|
||||
for i := 4; i >= 0; i-- {
|
||||
year := time.Now().AddDate(-i, 0, 0).Format("2006")
|
||||
|
||||
var count int64
|
||||
scopeStatsImages(db, roleID, userID, userUUID).
|
||||
Where(createdAtPrefix(db, "year")+" = ?", year).
|
||||
Count(&count)
|
||||
|
||||
stats = append(stats, UploadTrendItem{
|
||||
Date: year,
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
c.JSON(http.StatusOK, StatsResponse{Code: 200, Message: "获取图片统计成功", Success: true, Data: stats})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"net/http"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/storagepolicy"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func GetStorageAssignments(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
user, ok := middlewares.GetCurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(401, result.Error(401, "未登录"))
|
||||
return
|
||||
}
|
||||
s, buckets, roles, err := readStoragePolicyOverview()
|
||||
if err != nil {
|
||||
internalFailure(c, "获取角色存储策略失败", err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, result.Success("ok", gin.H{"multi_storage_sync": s.MultiStorageSync, "guest_enabled": s.Tourist, "default_storage_id": s.DefaultStorage, "buckets": buckets, "roles": roles, "can_edit": canEditStoragePolicy(c, user)}))
|
||||
}
|
||||
|
||||
func UpdateStorageAssignment(c *gin.Context) {
|
||||
user, ok := middlewares.GetCurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(401, result.Error(401, "未登录"))
|
||||
return
|
||||
}
|
||||
if !canEditStoragePolicy(c, user) {
|
||||
c.JSON(403, result.Error(403, "需要上传与存储设置权限"))
|
||||
return
|
||||
}
|
||||
role, err := strconv.Atoi(c.Param("role"))
|
||||
if err != nil || !storagepolicy.ValidRole(role) {
|
||||
c.JSON(400, result.Error(400, "角色无效"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
BucketIDs []int `json:"bucket_ids"`
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.BucketIDs == nil || (req.Mode != "legacy" && req.Mode != "custom") || len(req.BucketIDs) > 200 {
|
||||
c.JSON(400, result.Error(400, "请提交 legacy/custom 模式及最多 200 个存储源 ID 数组"))
|
||||
return
|
||||
}
|
||||
if req.Mode == "legacy" && len(req.BucketIDs) > 0 {
|
||||
c.JSON(400, result.Error(400, "恢复默认策略时 bucket_ids 必须为空数组"))
|
||||
return
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, id := range req.BucketIDs {
|
||||
if id <= 0 || seen[id] {
|
||||
c.JSON(400, result.Error(400, "存储源 ID 必须为唯一正整数"))
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
slices.Sort(req.BucketIDs)
|
||||
db := database.GetDB().DB
|
||||
// Serialize policy writes through the existing singleton settings row. Do
|
||||
// not create default policies or mutate per-user grants/auth settings.
|
||||
var validation string
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var s models.Settings
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&s, 1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(req.BucketIDs) > 0 {
|
||||
var buckets []models.Buckets
|
||||
if err := tx.Where("id IN ?", req.BucketIDs).Find(&buckets).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(buckets) != len(req.BucketIDs) {
|
||||
validation = "包含不存在的存储源"
|
||||
return nil
|
||||
}
|
||||
var old models.RoleStoragePolicy
|
||||
if err := tx.Where("role = ?", role).Find(&old).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range buckets {
|
||||
if s.MultiStorageSync && b.Type == "default" {
|
||||
validation = "多存储模式的本机源固定保留,请仅选择远端同步源"
|
||||
return nil
|
||||
}
|
||||
if b.Disabled && !(old.Mode == "custom" && slices.Contains(old.BucketIDs, b.Id)) {
|
||||
validation = "不能新选择已停用的存储源"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Mode == "legacy" {
|
||||
return tx.Delete(&models.RoleStoragePolicy{}, "role = ?", role).Error
|
||||
}
|
||||
row := models.RoleStoragePolicy{Role: role, Mode: req.Mode, BucketIDs: req.BucketIDs}
|
||||
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "role"}}, DoUpdates: clause.AssignmentColumns([]string{"mode", "bucket_ids"})}).Create(&row).Error
|
||||
})
|
||||
if err != nil {
|
||||
internalFailure(c, "更新角色存储策略失败", err)
|
||||
return
|
||||
}
|
||||
if validation != "" {
|
||||
c.JSON(400, result.Error(400, validation))
|
||||
return
|
||||
}
|
||||
// Policy reads are uncached; a subsequent GET reads the committed row.
|
||||
c.JSON(200, result.Success("角色存储分配已更新;用户额外授权不变", nil))
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/storagepolicy"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -31,9 +31,8 @@ type ImageStorageStatusResponse struct {
|
||||
}
|
||||
|
||||
// resolveUploadBuckets returns the durable local source and the remote targets
|
||||
// assigned to the current user. In multi-storage mode every persisted user,
|
||||
// including administrators, has an explicit list; guests retain the system
|
||||
// default because they have no user record.
|
||||
// assigned by the shared role pool and any applicable per-user extra grants.
|
||||
// Guests never inherit per-user grants; the local canonical source stays fixed.
|
||||
func resolveUploadBuckets(c *gin.Context, setting models.Settings) (models.Buckets, []models.Buckets, error) {
|
||||
db := database.GetDB()
|
||||
if db == nil || db.DB == nil {
|
||||
@@ -71,28 +70,14 @@ func resolveUploadBuckets(c *gin.Context, setting models.Settings) (models.Bucke
|
||||
}
|
||||
}
|
||||
|
||||
targets := make([]models.Buckets, 0, len(allBuckets))
|
||||
for _, bucket := range allBuckets {
|
||||
if bucket.Disabled {
|
||||
continue
|
||||
}
|
||||
if bucket.Id == localBucket.Id || bucket.Type == "default" {
|
||||
continue
|
||||
}
|
||||
|
||||
allowed := models.IntSliceContains(permission.Buckets, bucket.Id)
|
||||
if role == models.RoleGuest {
|
||||
allowed = bucket.Id == setting.DefaultStorage
|
||||
}
|
||||
if allowed {
|
||||
targets = append(targets, bucket)
|
||||
}
|
||||
policy, err := storagepolicy.Load(db.DB)
|
||||
if err != nil {
|
||||
return models.Buckets{}, nil, err
|
||||
}
|
||||
|
||||
return localBucket, targets, nil
|
||||
return localBucket, policy.Select(role, permission, setting, allBuckets), nil
|
||||
}
|
||||
|
||||
// resolveLegacyUploadBuckets preserves the single-storage selector semantics.
|
||||
// resolveLegacyUploadBuckets selects request-time single-storage destinations.
|
||||
func resolveLegacyUploadBuckets(c *gin.Context, setting models.Settings) ([]models.Buckets, error) {
|
||||
db := database.GetDB()
|
||||
if db == nil || db.DB == nil {
|
||||
@@ -117,25 +102,11 @@ func resolveLegacyUploadBuckets(c *gin.Context, setting models.Settings) ([]mode
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]models.Buckets, 0, len(allBuckets))
|
||||
for _, bucket := range allBuckets {
|
||||
if bucket.Disabled {
|
||||
continue
|
||||
}
|
||||
if bucket.Id != setting.DefaultStorage {
|
||||
if bucket.Capacity > 0 && bucket.Usage >= bucket.Capacity {
|
||||
continue
|
||||
}
|
||||
if role != models.RoleAdmin && !models.IntSliceContains(permission.Buckets, bucket.Id) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if role == models.RoleGuest && bucket.Id != setting.DefaultStorage {
|
||||
continue
|
||||
}
|
||||
result = append(result, bucket)
|
||||
policy, err := storagepolicy.Load(db.DB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
return policy.Select(role, permission, setting, allBuckets), nil
|
||||
}
|
||||
|
||||
func canUseLegacyUploadBucket(c *gin.Context, setting models.Settings, bucketID int) (bool, error) {
|
||||
@@ -213,14 +184,10 @@ func loadImageStorageStatuses(imageIDs []int, setting models.Settings) (map[int]
|
||||
|
||||
func cleanupLocalUpload(image models.Image) {
|
||||
for _, publicPath := range []string{image.Url, image.Thumbnail} {
|
||||
path := strings.TrimSpace(publicPath)
|
||||
if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
path, err := config.LocalUploadPath(publicPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(path, "/")))
|
||||
if cleanPath == "." || filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(cleanPath)
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/uploadpolicy"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
@@ -24,8 +27,13 @@ func AddTag(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签名称不能为空"))
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(tag.Name) > 10 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签名称过长"))
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取标签配置失败"))
|
||||
return
|
||||
}
|
||||
if err := uploadpolicy.ValidateTag(setting, tag.Name); err != nil {
|
||||
c.JSON(400, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -90,7 +98,11 @@ func DeleteTag(c *gin.Context) {
|
||||
db := database.GetDB().DB
|
||||
var tag models.Tags
|
||||
if err := db.First(&tag, uint(id)).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "标签不存在"))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "标签不存在"))
|
||||
} else {
|
||||
internalFailure(c, "查询标签失败", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := db.Where("tag_id = ?", id).Delete(&models.ImageToTags{}).Error; err != nil {
|
||||
@@ -134,15 +146,24 @@ func UpdateTag(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签名称不能为空"))
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(req.Name) > 10 {
|
||||
c.JSON(http.StatusBadRequest, result.Error(400, "标签名称过长"))
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "获取标签配置失败"))
|
||||
return
|
||||
}
|
||||
if err := uploadpolicy.ValidateTag(setting, req.Name); err != nil {
|
||||
c.JSON(400, result.Error(400, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().DB
|
||||
var tag models.Tags
|
||||
if err := db.First(&tag, uint(id)).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "标签不存在"))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, result.Error(404, "标签不存在"))
|
||||
} else {
|
||||
internalFailure(c, "查询标签失败", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/uploadpolicy"
|
||||
)
|
||||
|
||||
var uploadRequestSlots = make(chan struct{}, 2)
|
||||
|
||||
func acquireUploadRequest(c *gin.Context) (func(), bool) {
|
||||
select {
|
||||
case uploadRequestSlots <- struct{}{}:
|
||||
return func() { <-uploadRequestSlots }, true
|
||||
default:
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 429, "message": "上传处理中,请稍后重试"})
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// parseConfiguredUploadFiles replaces the legacy fixed-count parser at both
|
||||
// controller entry points without changing storage uploader interfaces.
|
||||
func parseConfiguredUploadFiles(c *gin.Context, s models.Settings) ([]*multipart.FileHeader, error) {
|
||||
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Request.MultipartForm == nil {
|
||||
return nil, fmt.Errorf("请选择要上传的图片")
|
||||
}
|
||||
files := c.Request.MultipartForm.File["images[]"]
|
||||
if err := uploadpolicy.ValidateCount(s, len(files)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
func uploadParseErrorStatus(err error) int {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return http.StatusRequestEntityTooLarge
|
||||
}
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
func validateUploadTags(s models.Settings, tags []string) error {
|
||||
if len(tags) > 100 {
|
||||
return fmt.Errorf("每次最多选择100个标签")
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if err := uploadpolicy.ValidateTag(s, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/images"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Return only raster formats the actual decoder and current policy both allow.
|
||||
func effectiveUploadMIMEs(setting models.Settings) []string {
|
||||
allowed := make([]string, 0, 4)
|
||||
for _, mime := range []string{"image/jpeg", "image/png", "image/gif", "image/webp"} {
|
||||
if images.AllowedMIME(mime, strings.Split(setting.AllowedTypes, ",")) {
|
||||
allowed = append(allowed, mime)
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/interfaces"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/utils/uploads"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
func failedUploadResult(file *multipart.FileHeader,message string)interfaces.ImageUploadResult{
|
||||
return interfaces.ImageUploadResult{Success:false,FileName:file.Filename,Message:message}
|
||||
}
|
||||
func respondUploadBatch(uc *uploads.UploadContext,results []interfaces.ImageUploadResult,message string,targets int){
|
||||
count:=0;for _,r:=range results{if r.Success{count++}}
|
||||
status:=200;if count==0{status=400;message="所有文件上传失败"}else if count<len(results){message="部分文件上传成功,请查看逐项结果"}
|
||||
uc.Gin().JSON(status,gin.H{"code":status,"message":message,"data":map[string]any{"files":results,"count":count,"failed_count":len(results)-count,"sync_targets":targets}})
|
||||
}
|
||||
func chargeUploadQuota(tx *gorm.DB,bucket models.Buckets,result *interfaces.ImageUploadResult)error{
|
||||
size:=result.FileSize+result.ThumbnailSize
|
||||
if size<0{return fmt.Errorf("invalid upload size")}
|
||||
col:=database.UsageColumn(tx)
|
||||
query:=tx.Model(&models.Buckets{}).Where("id = ?",bucket.Id)
|
||||
if bucket.Type!="default" && bucket.Type!="telegram"{
|
||||
// subtraction form cannot overflow and includes equality at the boundary.
|
||||
query=query.Where("capacity = 0 OR ("+col+" <= capacity AND capacity - "+col+" >= ?)",size)
|
||||
}
|
||||
update:=query.UpdateColumn("usage",gorm.Expr(col+" + ?",size))
|
||||
if update.Error!=nil{return update.Error};if update.RowsAffected!=1{return fmt.Errorf("bucket quota exhausted")};return nil
|
||||
}
|
||||
func cleanupUnpublishedUpload(c *gin.Context,image models.Image,result *interfaces.ImageUploadResult){
|
||||
if image.Storage=="default"{cleanupLocalUpload(image);return}
|
||||
// No published Image exists, but materialize a retryable deletion manifest so
|
||||
// an SDK failure cannot orphan already stored objects invisibly.
|
||||
db:=database.GetDB().DB
|
||||
image.Id=0;image.Deleting=true;image.FolderId=0
|
||||
err:=db.Transaction(func(tx *gorm.DB)error{
|
||||
if err:=tx.Create(&image).Error;err!=nil{return err}
|
||||
replica:=models.ImageStorage{ImageID:image.Id,BucketID:image.BucketId,Storage:image.Storage,Status:models.ImageStorageStatusDeleting,URL:image.Url,Thumbnail:image.Thumbnail,FileSize:result.FileSize,ThumbnailSize:result.ThumbnailSize,Metadata:map[string]any{"delete_charged":false}}
|
||||
return tx.Create(&replica).Error
|
||||
})
|
||||
if err==nil { if services.DeleteImageReplicas(context.Background(),image)==nil { _=db.Delete(&image).Error } }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/storagepolicy"
|
||||
)
|
||||
|
||||
// Read-only metadata describes effective access, not the raw extra-grant arrays.
|
||||
// It is never used to grant authorization or persisted back to Permission.
|
||||
type UserAccessSummary struct {
|
||||
StorageMode string `json:"storage_mode"`
|
||||
StorageCount int `json:"storage_count"`
|
||||
DefaultStorageID int `json:"default_storage_id"`
|
||||
DefaultStorageIncluded bool `json:"default_storage_included"`
|
||||
LocalStorageIncluded bool `json:"local_storage_included"`
|
||||
AllManagementPermissions bool `json:"all_management_permissions"`
|
||||
AdditionalPermissionCount int `json:"additional_permission_count"`
|
||||
InheritedBucketIDs []int `json:"inherited_bucket_ids"`
|
||||
}
|
||||
type UserWithAccessSummary struct {
|
||||
models.User
|
||||
AccessSummary UserAccessSummary `json:"access_summary"`
|
||||
}
|
||||
|
||||
func summarizeUserAccess(user models.User, setting models.Settings, buckets []models.Buckets, policies ...storagepolicy.Policies) UserAccessSummary {
|
||||
policy := storagepolicy.Policies{}
|
||||
if len(policies) > 0 {
|
||||
policy = policies[0]
|
||||
}
|
||||
s := UserAccessSummary{StorageMode: "single", DefaultStorageID: storagepolicy.DefaultID(user.Role, setting), AllManagementPermissions: user.ID == models.SuperAdminID || user.Permission.HasPermission("*"), InheritedBucketIDs: policy.InheritedIDs(user.Role, setting, buckets)}
|
||||
codes := map[string]bool{}
|
||||
for _, code := range user.Permission.Codes {
|
||||
if _, ok := models.AllPermissionMap[code]; ok {
|
||||
codes[code] = true
|
||||
}
|
||||
}
|
||||
s.AdditionalPermissionCount = len(codes)
|
||||
available := policy.Select(user.Role, user.Permission, setting, buckets)
|
||||
s.StorageCount = len(available)
|
||||
if setting.MultiStorageSync {
|
||||
s.StorageMode = "multi"
|
||||
for _, bucket := range buckets {
|
||||
if bucket.Type == "default" {
|
||||
s.LocalStorageIncluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, bucket := range available {
|
||||
if bucket.Id == s.DefaultStorageID {
|
||||
s.DefaultStorageIncluded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"oneimg/backend/utils/result"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"oneimg/backend/middlewares"
|
||||
"oneimg/backend/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CheckLoginStatus 返回当前登录用户的基本会话信息。
|
||||
// 前置:AuthMiddleware。
|
||||
func CheckLoginStatus(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
username := session.Get("username")
|
||||
role := c.GetInt("user_role")
|
||||
|
||||
c.JSON(http.StatusOK, result.Success("已登录", map[string]any{
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
"logged_in": true,
|
||||
"user_role": role,
|
||||
}))
|
||||
u,ok:=middlewares.GetCurrentUser(c)
|
||||
if !ok { c.JSON(401,gin.H{"code":401,"message":"未登录"}); return }
|
||||
c.JSON(http.StatusOK,gin.H{"code":200,"message":"已登录","data":gin.H{
|
||||
"id":u.ID,"username":u.Username,"role":u.Role,"isLoggedIn":true,
|
||||
"user_id":u.ID,"user_role":u.Role,"logged_in":true,
|
||||
"isSuperAdmin":u.ID==models.SuperAdminID && c.GetString("auth_method")!="api_token",
|
||||
"isAdmin":u.Role==models.RoleAdmin,"permissions":u.Permission.Codes,
|
||||
"guest_identity_verified":c.GetBool("guest_identity_verified"),
|
||||
"auth_method":c.GetString("auth_method"),
|
||||
}})
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,14 +11,17 @@ import (
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/result"
|
||||
"oneimg/backend/utils/settings"
|
||||
"oneimg/backend/utils/storagepolicy"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// GetUsers 分页查询用户列表(管理员)。
|
||||
func GetUsers(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
db := database.GetDB().DB
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
@@ -45,19 +48,39 @@ func GetUsers(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Fail(500, "查询总数失败:"+err.Error()))
|
||||
internalFailure(c, "查询总数失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(limit).Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Fail(500, "查询用户列表失败:"+err.Error()))
|
||||
internalFailure(c, "查询用户列表失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
setting, err := settings.GetSettings()
|
||||
if err != nil {
|
||||
internalFailure(c, "读取用户权限摘要失败", err)
|
||||
return
|
||||
}
|
||||
var buckets []models.Buckets
|
||||
if err := db.Select("id", "type", "disabled", "capacity", "usage").Find(&buckets).Error; err != nil {
|
||||
internalFailure(c, "读取存储可用状态失败", err)
|
||||
return
|
||||
}
|
||||
policy, err := storagepolicy.Load(db)
|
||||
if err != nil {
|
||||
internalFailure(c, "读取角色存储策略失败", err)
|
||||
return
|
||||
}
|
||||
list := make([]UserWithAccessSummary, 0, len(users))
|
||||
for _, user := range users {
|
||||
list = append(list, UserWithAccessSummary{User: user, AccessSummary: summarizeUserAccess(user, setting, buckets, policy)})
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("查询成功", map[string]any{
|
||||
"total": total,
|
||||
"list": users,
|
||||
"total": total,
|
||||
"list": list,
|
||||
"multi_storage_sync": setting.MultiStorageSync,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -65,7 +88,7 @@ func GetUsers(c *gin.Context) {
|
||||
func CreateUser(c *gin.Context) {
|
||||
type CreateUserReq struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=6,max=100"`
|
||||
Password string `json:"password" binding:"required,min=8,max=72"`
|
||||
Role int `json:"role" binding:"required,oneof=1 3"`
|
||||
}
|
||||
var req CreateUserReq
|
||||
@@ -74,6 +97,10 @@ func CreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username != strings.TrimSpace(req.Username) || isTouristUsername(req.Username) || len(req.Password) > 72 {
|
||||
c.JSON(400, result.Error(400, "用户名为保留名称或密码长度无效"))
|
||||
return
|
||||
}
|
||||
db := database.GetDB().DB
|
||||
|
||||
if db.Where("username = ?", req.Username).First(&models.User{}).Error == nil {
|
||||
@@ -154,9 +181,18 @@ func DeleteUser(c *gin.Context) {
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authsecurity.RevokeUserSessions(tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.APICredential{}).Where("owner_id = ?", user.ID).Update("revoked", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", user.ID).Delete(&models.AuthLoginEvent{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&user).Error
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Fail(500, "删除用户失败:"+err.Error()))
|
||||
internalFailure(c, "删除用户失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -194,8 +230,13 @@ func UpdateUserRole(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&user).Update("role", req.Role).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Fail(500, "更新角色失败:"+err.Error()))
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&user).Update("role", req.Role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return authsecurity.RevokeUserSessions(tx, user.ID)
|
||||
}); err != nil {
|
||||
internalFailure(c, "更新角色失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -231,15 +272,27 @@ func ResetPassword(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 生成12位友好随机密码
|
||||
newPassword := generateRandomSecret(12)
|
||||
newPassword, err := authsecurity.RandomSecret(18)
|
||||
if err != nil {
|
||||
c.JSON(500, result.Error(500, "随机密码生成失败"))
|
||||
return
|
||||
}
|
||||
hashedPwd, err := hashPassword(newPassword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Fail(500, "密码加密失败"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&user).Update("password", hashedPwd).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Fail(500, "重置密码失败:"+err.Error()))
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&user).Update("password", hashedPwd).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authsecurity.RevokeUserSessions(tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&models.APICredential{}).Where("owner_id = ?", user.ID).Update("revoked", true).Error
|
||||
}); err != nil {
|
||||
internalFailure(c, "重置密码失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -324,29 +377,39 @@ func UpdateUserPermission(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uniquePermissions = user.Permission.Buckets
|
||||
}
|
||||
|
||||
var validCodes []string
|
||||
if req.Codes != nil {
|
||||
validCodes = models.FilterValidPermissionCodes(req.Codes)
|
||||
} else {
|
||||
validCodes = user.Permission.Codes
|
||||
}
|
||||
|
||||
if validCodes == nil {
|
||||
validCodes = []string{}
|
||||
}
|
||||
if uniquePermissions == nil {
|
||||
uniquePermissions = []int{}
|
||||
}
|
||||
|
||||
if err := db.Model(&user).Update("permission", models.Permission{
|
||||
Codes: validCodes,
|
||||
Buckets: uniquePermissions,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, result.Fail(500, "更新权限失败:"+err.Error()))
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Reload under the row lock, then merge only supplied fields. Independent
|
||||
// storage/ordinary-permission editors must preserve concurrent edits.
|
||||
var current models.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
permission := current.Permission
|
||||
if req.Permission != nil {
|
||||
permission.Buckets = uniquePermissions
|
||||
}
|
||||
if req.Codes != nil {
|
||||
permission.Codes = validCodes
|
||||
}
|
||||
if permission.Codes == nil {
|
||||
permission.Codes = []string{}
|
||||
}
|
||||
if permission.Buckets == nil {
|
||||
permission.Buckets = []int{}
|
||||
}
|
||||
if err := tx.Model(¤t).Update("permission", permission).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return authsecurity.RevokeUserSessions(tx, current.ID)
|
||||
}); err != nil {
|
||||
internalFailure(c, "更新权限失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -362,13 +425,3 @@ func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
// generateRandomSecret 生成指定长度的字母数字随机串。
|
||||
func generateRandomSecret(length int) string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -25,6 +25,9 @@ var cappowStore = cappow.NewStore()
|
||||
|
||||
// cappowSecret 派生 cap-pow 的 HMAC 密钥(稳定、跨重启一致,无需后台配置)。
|
||||
func cappowSecret() []byte {
|
||||
if config.App == nil {
|
||||
return nil
|
||||
}
|
||||
return cappow.DeriveSecret(config.App.ConfigSecret)
|
||||
}
|
||||
|
||||
@@ -101,7 +104,7 @@ func validateTurnstileSiteKeyAPI(sitekey, token, accountID string) error {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
|
||||
var apiResp struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -145,6 +148,9 @@ func turnstileConfigured(s models.Settings) bool {
|
||||
// 时回退到 cap-pow 本地验证。
|
||||
func effectiveVerifyMethodWithFallback(s models.Settings) string {
|
||||
method := models.EffectiveVerifyMethod(s)
|
||||
if method == models.VerifyMethodPOW && s.PowLocalFallback {
|
||||
return models.VerifyMethodCappow
|
||||
}
|
||||
if method == models.VerifyMethodTurnstile && !turnstileConfigured(s) {
|
||||
return models.VerifyMethodCappow
|
||||
}
|
||||
@@ -178,6 +184,9 @@ func verifyHuman(c *gin.Context, setting models.Settings, powToken, turnstileTok
|
||||
return true, "", ""
|
||||
case models.VerifyMethodCappow:
|
||||
if capToken == "" {
|
||||
if models.EffectiveVerifyMethod(setting) == models.VerifyMethodPOW && setting.PowLocalFallback {
|
||||
return false, "请完成本地人机验证", models.VerifyMethodCappow
|
||||
}
|
||||
return false, "请完成人机验证", ""
|
||||
}
|
||||
if !cappowStore.ConsumeToken(capToken) {
|
||||
@@ -188,7 +197,7 @@ func verifyHuman(c *gin.Context, setting models.Settings, powToken, turnstileTok
|
||||
if powToken == "" {
|
||||
return false, "请输入pow token", ""
|
||||
}
|
||||
if !ValidatePowToken(powToken) {
|
||||
if !ValidatePowTokenWithSettings(c.Request.Context(), setting, powToken) {
|
||||
return false, "pow token验证失败", ""
|
||||
}
|
||||
return true, "", ""
|
||||
@@ -238,7 +247,7 @@ func ValidateTurnstileToken(token, secret, remoteIP string) (bool, []string) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
@@ -281,20 +290,20 @@ func CapPowRedeem(c *gin.Context) {
|
||||
Solutions []int `json:"solutions"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "invalid_body"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请求参数错误", "success": false, "error": "invalid_body"})
|
||||
return
|
||||
}
|
||||
|
||||
secret := cappowSecret()
|
||||
ok, _ := cappow.ValidateChallenge(secret, req.Token, req.Solutions)
|
||||
if !ok {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false})
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "人机验证失败,请重试", "success": false})
|
||||
return
|
||||
}
|
||||
|
||||
// 一次性挑战重放防护:消费失败说明该挑战已被兑换过。
|
||||
if !cappowStore.ConsumeChallengeSig(cappow.JwtSigHex(req.Token), 0) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false})
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "人机验证失败,请重试", "success": false})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/models"
|
||||
@@ -29,9 +33,10 @@ type Database struct {
|
||||
var db *Database
|
||||
|
||||
// NewDB 使用给定 dialector 打开并校验数据库连接。
|
||||
func NewDB(dialector gorm.Dialector) (*Database, error) {
|
||||
func NewDB(dialector gorm.Dialector, configs ...*config.Config) (*Database, error) {
|
||||
gormConfig := &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
DisableAutomaticPing: true,
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
}
|
||||
|
||||
@@ -45,7 +50,15 @@ func NewDB(dialector gorm.Dialector) (*Database, error) {
|
||||
return nil, fmt.Errorf("获取SQL连接失败: %w", err)
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
var cfg *config.Config
|
||||
if len(configs) > 0 {
|
||||
cfg = configs[0]
|
||||
}
|
||||
configurePool(sqlDB, cfg, dialector.Name())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("连接验证失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -94,7 +107,7 @@ func InitDB(cfg *config.Config) {
|
||||
}
|
||||
|
||||
// 创建数据库实例
|
||||
db, err = NewDB(dialector)
|
||||
db, err = NewDB(dialector, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 数据库实例创建失败: %v", err)
|
||||
}
|
||||
@@ -103,26 +116,49 @@ func InitDB(cfg *config.Config) {
|
||||
err = db.DB.AutoMigrate(
|
||||
&models.Tags{},
|
||||
&models.User{},
|
||||
&models.AuthSession{},
|
||||
&models.AuthLoginEvent{},
|
||||
&models.AuthState{},
|
||||
&models.GuestIdentity{},
|
||||
&models.APICredential{},
|
||||
&models.Folder{},
|
||||
&models.Image{},
|
||||
&models.ImageStorage{},
|
||||
&models.DirectUploadTask{},
|
||||
&models.Settings{},
|
||||
&models.ExternalAuthFlow{},
|
||||
&models.ExternalIdentity{},
|
||||
&models.ImageTeleGram{},
|
||||
&models.ImageToTags{},
|
||||
&models.Buckets{},
|
||||
&models.RoleStoragePolicy{},
|
||||
&models.RandomGraph{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 数据库表迁移失败: %v", err)
|
||||
}
|
||||
if err := EnsureSettingsSingleton(db.DB); err != nil {
|
||||
log.Fatalf("❌ 系统配置单行迁移失败: %v", err)
|
||||
}
|
||||
if err := EnsureQueryIndexes(db.DB); err != nil {
|
||||
log.Fatalf("❌ 查询索引迁移失败: %v", err)
|
||||
}
|
||||
log.Println("✅ 数据库表迁移完成")
|
||||
}
|
||||
|
||||
// initMysqlWithTLS 初始化MySQL
|
||||
func initMysqlWithTLS(cfg *config.Config) (gorm.Dialector, error) {
|
||||
dsnTemplate := "%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s"
|
||||
dsn := fmt.Sprintf(dsnTemplate, cfg.DbUser, cfg.DbPassword, cfg.DbHost, cfg.DbPort, cfg.DbName)
|
||||
|
||||
mysqlConfig := sqlmysql.NewConfig()
|
||||
mysqlConfig.User = cfg.DbUser
|
||||
mysqlConfig.Passwd = cfg.DbPassword
|
||||
mysqlConfig.Net = "tcp"
|
||||
mysqlConfig.Addr = net.JoinHostPort(cfg.DbHost, fmt.Sprint(cfg.DbPort))
|
||||
mysqlConfig.DBName = cfg.DbName
|
||||
mysqlConfig.ParseTime = true
|
||||
mysqlConfig.Loc = time.Local
|
||||
mysqlConfig.Timeout = 10 * time.Second
|
||||
mysqlConfig.Params = map[string]string{"charset": "utf8mb4"}
|
||||
|
||||
tlsName := "custom_tls"
|
||||
tlsConfig, err := buildTLSConfig(cfg)
|
||||
@@ -133,52 +169,45 @@ func initMysqlWithTLS(cfg *config.Config) (gorm.Dialector, error) {
|
||||
if err := sqlmysql.RegisterTLSConfig(tlsName, tlsConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dsn += "&tls=" + tlsName
|
||||
|
||||
return mysql.New(mysql.Config{DSN: dsn}), nil
|
||||
mysqlConfig.TLSConfig = tlsName
|
||||
return mysql.New(mysql.Config{DSN: mysqlConfig.FormatDSN()}), nil
|
||||
}
|
||||
|
||||
// initPostgreSQLWithTLS 初始化 PG 数据库
|
||||
func initPostgreSQLWithTLS(cfg *config.Config) (gorm.Dialector, error) {
|
||||
// 对特殊字符进行编码
|
||||
user := url.QueryEscape(cfg.DbUser)
|
||||
pass := url.QueryEscape(cfg.DbPassword)
|
||||
|
||||
// 构建基础 DSN
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s", user, pass, cfg.DbHost, cfg.DbPort, cfg.DbName)
|
||||
|
||||
// 处理 SSL/TLS 参数
|
||||
queryParams := url.Values{}
|
||||
queryParams.Add("timezone", "Asia/Shanghai")
|
||||
|
||||
if cfg.DbCaCertPath != "" && fileExists(cfg.DbCaCertPath) {
|
||||
absPath, _ := filepath.Abs(cfg.DbCaCertPath)
|
||||
if cfg.DbSkipCertVerify {
|
||||
queryParams.Add("sslmode", "require")
|
||||
} else {
|
||||
queryParams.Add("sslmode", "verify-full")
|
||||
queryParams.Add("sslrootcert", absPath)
|
||||
}
|
||||
} else {
|
||||
queryParams.Add("sslmode", "require")
|
||||
u := &url.URL{Scheme: "postgres", User: url.UserPassword(cfg.DbUser, cfg.DbPassword), Host: net.JoinHostPort(cfg.DbHost, fmt.Sprint(cfg.DbPort)), Path: "/" + cfg.DbName}
|
||||
params := url.Values{"timezone": {"Asia/Shanghai"}, "connect_timeout": {"10"}, "sslmode": {"verify-full"}}
|
||||
if cfg.DbSkipCertVerify {
|
||||
params.Set("sslmode", "require")
|
||||
}
|
||||
|
||||
dsn = dsn + "?" + queryParams.Encode()
|
||||
|
||||
return postgres.New(postgres.Config{
|
||||
DSN: dsn,
|
||||
PreferSimpleProtocol: true, // 增加兼容性
|
||||
}), nil
|
||||
if cfg.DbCaCertPath != "" {
|
||||
certPath, err := validateCAFile(cfg.DbCaCertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params.Set("sslrootcert", certPath)
|
||||
}
|
||||
// Missing configured CA is an error, never an implicit downgrade to require.
|
||||
// GORM's timezone matcher reads the raw query before URL decoding. Keep
|
||||
// the zone's slash literal (valid in a query), without unescaping secrets.
|
||||
u.RawQuery = strings.Replace(params.Encode(), "timezone=Asia%2FShanghai", "timezone=Asia/Shanghai", 1)
|
||||
return postgres.New(postgres.Config{DSN: u.String(), PreferSimpleProtocol: true}), nil
|
||||
}
|
||||
|
||||
// buildTLSConfig 构建 TLS 配置
|
||||
func buildTLSConfig(cfg *config.Config) (*tls.Config, error) {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: cfg.DbSkipCertVerify,
|
||||
ServerName: cfg.DbHost,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
if cfg.DbCaCertPath != "" && fileExists(cfg.DbCaCertPath) {
|
||||
caCert, err := os.ReadFile(cfg.DbCaCertPath)
|
||||
if cfg.DbCaCertPath != "" {
|
||||
certPath, err := validateCAFile(cfg.DbCaCertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caCert, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取CA证书失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Legacy settings rows are archived verbatim before normalizing old databases.
|
||||
// A non-ID=1 canonical row retains every column, including false/zero values.
|
||||
type settingsLegacyArchive struct {
|
||||
OriginalID int `gorm:"primaryKey;autoIncrement:false"`
|
||||
RowJSON string `gorm:"type:text;not null"`
|
||||
ArchivedAt time.Time
|
||||
}
|
||||
|
||||
func (settingsLegacyArchive) TableName() string { return "settings_legacy_archive" }
|
||||
|
||||
// EnsureSettingsSingleton preserves the row previously selected by First(),
|
||||
// archives additional legacy rows and enforces ID=1 for future writes.
|
||||
func EnsureSettingsSingleton(db *gorm.DB) error {
|
||||
// MySQL DDL implicitly commits. Create archival storage before changing any
|
||||
// legacy row, then do all row normalization in a separate real transaction.
|
||||
var needsArchive int64
|
||||
if err := db.Table("settings").Count(&needsArchive).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var idOneCount int64
|
||||
if err := db.Table("settings").Where("id = ?", 1).Count(&idOneCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if needsArchive > 1 || (needsArchive == 1 && idOneCount == 0) {
|
||||
if err := db.AutoMigrate(&settingsLegacyArchive{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
if err := tx.Exec("LOCK TABLE settings IN EXCLUSIVE MODE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := tx.Table("settings").Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
var canonicalID int
|
||||
if err := tx.Table("settings").Select("id").Order("id ASC").Limit(1).Scan(&canonicalID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var hasCanonical int64
|
||||
if err := tx.Table("settings").Where("id = ?", 1).Count(&hasCanonical).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if hasCanonical > 0 {
|
||||
canonicalID = 1
|
||||
}
|
||||
if canonicalID != 1 || len(rows) > 1 {
|
||||
for _, row := range rows {
|
||||
encoded, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var id int
|
||||
switch v := row["id"].(type) {
|
||||
case []byte:
|
||||
parsed, e := strconv.Atoi(string(v))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
id = parsed
|
||||
case string:
|
||||
parsed, e := strconv.Atoi(v)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
id = parsed
|
||||
case int64:
|
||||
id = int(v)
|
||||
case int:
|
||||
id = v
|
||||
default:
|
||||
return fmt.Errorf("unsupported settings ID type %T", v)
|
||||
}
|
||||
if err := tx.Create(&settingsLegacyArchive{OriginalID: id, RowJSON: string(encoded), ArchivedAt: time.Now()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Table("settings").Where("id <> ?", canonicalID).Delete(nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if canonicalID != 1 {
|
||||
if err := tx.Exec("UPDATE settings SET id = 1 WHERE id = ?", canonicalID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Startup is single-app: install the invariant before accepting requests.
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
switch tx.Dialector.Name() {
|
||||
case "sqlite":
|
||||
for _, event := range []string{"INSERT", "UPDATE"} {
|
||||
sql := fmt.Sprintf("CREATE TRIGGER IF NOT EXISTS settings_singleton_%s BEFORE %s ON settings WHEN NEW.id IS NULL OR NEW.id <> 1 BEGIN SELECT RAISE(ABORT, 'settings ID must be 1'); END", event, event)
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
if !tx.Migrator().HasConstraint("settings", "settings_singleton_id") {
|
||||
if err := tx.Exec("ALTER TABLE settings ADD CONSTRAINT settings_singleton_id CHECK (id = 1)").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type queryIndex struct {
|
||||
table, name string
|
||||
columns []string
|
||||
optional bool
|
||||
}
|
||||
|
||||
// Separate URL/thumbnail B-trees let PostgreSQL BitmapOr serve the OR lookup.
|
||||
// A single (url,thumbnail) index would not accelerate thumbnail-only matches.
|
||||
func EnsureQueryIndexes(db *gorm.DB) error {
|
||||
indexes := []queryIndex{
|
||||
{"images", "idx_images_url", []string{"url"}, false},
|
||||
{"images", "idx_images_thumbnail", []string{"thumbnail"}, false},
|
||||
{"images", "idx_images_user_created", []string{"user_id", "created_at"}, false},
|
||||
{"images", "idx_images_uuid", []string{"uuid"}, false},
|
||||
{"images", "idx_images_bucket_id", []string{"bucket_id"}, false},
|
||||
{"images", "idx_images_created_at", []string{"created_at"}, false},
|
||||
{"images", "idx_images_md5", []string{"md5"}, false},
|
||||
{"images", "idx_images_tourist_fingerprint", []string{"tourist_fingerprint"}, true},
|
||||
{"images", "idx_images_uploaded_at", []string{"uploaded_at"}, true},
|
||||
// Existing PK(image_id,tag_id) covers forward joins; add reverse direction.
|
||||
{"image_to_tags", "idx_image_to_tags_tag_image", []string{"tag_id", "image_id"}, false},
|
||||
}
|
||||
for _, index := range indexes {
|
||||
if !db.Migrator().HasTable(index.table) {
|
||||
continue
|
||||
}
|
||||
missing := false
|
||||
for _, col := range index.columns {
|
||||
if !db.Migrator().HasColumn(index.table, col) {
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
if missing {
|
||||
if index.optional {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("index %s requires missing column", index.name)
|
||||
}
|
||||
if db.Migrator().HasIndex(index.table, index.name) {
|
||||
continue
|
||||
}
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
quote := func(s string) string { return stmt.Quote(s) }
|
||||
columns := ""
|
||||
for i, col := range index.columns {
|
||||
if i > 0 {
|
||||
columns += ", "
|
||||
}
|
||||
columns += quote(col)
|
||||
// MySQL TEXT cannot be indexed without a prefix. 191 utf8mb4 chars
|
||||
// avoids old InnoDB key limits; equality is still verified by WHERE.
|
||||
if db.Dialector.Name() == "mysql" && (col == "url" || col == "thumbnail" || col == "uuid" || col == "md5" || col == "tourist_fingerprint") {
|
||||
columns += "(191)"
|
||||
}
|
||||
}
|
||||
if err := db.Exec("CREATE INDEX " + quote(index.name) + " ON " + quote(index.table) + " (" + columns + ")").Error; err != nil {
|
||||
return fmt.Errorf("create %s: %w", index.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdvanceBucketSequence handles an explicit legacy/local ID=1 insertion. Never
|
||||
// lowers an existing sequence. Only the startup transaction calls this helper.
|
||||
func AdvanceBucketSequence(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
return tx.Exec(`SELECT setval(pg_get_serial_sequence('buckets','id'), GREATEST(COALESCE((SELECT MAX(id) FROM buckets),1), (SELECT last_value FROM buckets_id_seq)), true)`).Error
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"oneimg/backend/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func configurePool(db *sql.DB, cfg *config.Config, dialect string) {
|
||||
open, idle, lifetime, idleTime := 10, 2, 30*time.Minute, 5*time.Minute
|
||||
if cfg != nil {
|
||||
if cfg.DbMaxOpenConns > 0 {
|
||||
open = cfg.DbMaxOpenConns
|
||||
}
|
||||
if cfg.DbMaxIdleConns >= 0 {
|
||||
idle = cfg.DbMaxIdleConns
|
||||
}
|
||||
if cfg.DbConnMaxLifetime > 0 {
|
||||
lifetime = cfg.DbConnMaxLifetime
|
||||
}
|
||||
if cfg.DbConnMaxIdleTime > 0 {
|
||||
idleTime = cfg.DbConnMaxIdleTime
|
||||
}
|
||||
}
|
||||
// SQLite serializes writers; :memory: connections must share the same DB.
|
||||
if dialect == "sqlite" {
|
||||
open, idle, lifetime, idleTime = 1, 1, 0, 0
|
||||
}
|
||||
if idle > open {
|
||||
idle = open
|
||||
}
|
||||
db.SetMaxOpenConns(open)
|
||||
db.SetMaxIdleConns(idle)
|
||||
db.SetConnMaxLifetime(lifetime)
|
||||
db.SetConnMaxIdleTime(idleTime)
|
||||
}
|
||||
|
||||
func validateCAFile(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取配置的数据库 CA 失败: %w", err)
|
||||
}
|
||||
if !x509.NewCertPool().AppendCertsFromPEM(data) {
|
||||
return "", fmt.Errorf("数据库 CA 不是有效 PEM 证书")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"oneimg/backend/models"
|
||||
)
|
||||
|
||||
// BucketUsageReconciliation is an immutable audit record of the one-time
|
||||
// migration from legacy disk usage to accounted image artifact bytes.
|
||||
type BucketUsageReconciliation struct {
|
||||
BucketID int `gorm:"primaryKey;autoIncrement:false"`
|
||||
PreviousUsage uint64 `gorm:"not null"`
|
||||
AccountedUsage uint64 `gorm:"not null"`
|
||||
ReconciledAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
// ReconcileLegacyBucketUsage runs before workers start, after replica backfill.
|
||||
// Successful replicas are charged; partial deletions retain only the remaining
|
||||
// bytes. Failed unpublished cleanup manifests explicitly marked uncharged are
|
||||
// excluded. A per-bucket audit marker prevents every restart from resetting usage.
|
||||
func ReconcileLegacyBucketUsage(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&BucketUsageReconciliation{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var buckets []models.Buckets
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&buckets).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, bucket := range buckets {
|
||||
var recorded int64
|
||||
if err := tx.Model(&BucketUsageReconciliation{}).Where("bucket_id = ?", bucket.Id).Count(&recorded).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if recorded > 0 {
|
||||
continue
|
||||
}
|
||||
var replicas []models.ImageStorage
|
||||
if err := tx.Where("bucket_id = ?", bucket.Id).Find(&replicas).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var usage uint64
|
||||
for _, r := range replicas {
|
||||
charged := r.Status == models.ImageStorageStatusSuccess || r.SyncedAt != nil
|
||||
if r.Status == models.ImageStorageStatusDeleting {
|
||||
charged, _ = r.Metadata["delete_charged"].(bool)
|
||||
}
|
||||
if !charged {
|
||||
continue
|
||||
}
|
||||
if r.FileSize < 0 || r.ThumbnailSize < 0 {
|
||||
return fmt.Errorf("negative artifact accounting in replica %d", r.ID)
|
||||
}
|
||||
for _, size := range []int64{r.FileSize, r.ThumbnailSize} {
|
||||
if uint64(size) > math.MaxInt64-usage {
|
||||
return fmt.Errorf("artifact accounting overflow in bucket %d", bucket.Id)
|
||||
}
|
||||
usage += uint64(size)
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&models.Buckets{}).Where("id = ?", bucket.Id).UpdateColumn("usage", usage).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
audit := BucketUsageReconciliation{BucketID: bucket.Id, PreviousUsage: bucket.Usage, AccountedUsage: usage, ReconciledAt: time.Now()}
|
||||
if err := tx.Create(&audit).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type ImageUploadResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ID int `json:"id,omitempty"`
|
||||
FolderID int `json:"folder_id"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||
Storage string `json:"storage,omitempty"`
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
// Authentication JSON is bounded by the limiter (32 KiB). Other API JSON is
|
||||
// bounded separately; multipart uploads retain their request-specific limits.
|
||||
func AuthBodyLimitMiddleware(size int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path,"/api/") && strings.HasPrefix(strings.ToLower(c.ContentType()),"application/json") {
|
||||
if c.Request.ContentLength>size { rejectAuth(c,http.StatusRequestEntityTooLarge,"请求内容过大");return }
|
||||
c.Request.Body=http.MaxBytesReader(c.Writer,c.Request.Body,size)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,213 +1,106 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/secureconfig"
|
||||
"oneimg/backend/utils/settings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"oneimg/backend/utils/settings"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthResponse 认证失败时的统一响应体。
|
||||
type AuthResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
type AuthResponse struct { Code int `json:"code"`; Message string `json:"message"` }
|
||||
func rejectAuth(c *gin.Context,status int,message string) { c.AbortWithStatusJSON(status,AuthResponse{Code:status,Message:message}) }
|
||||
func setCurrentUser(c *gin.Context,u *models.User) {
|
||||
c.Set("user_id",u.ID); c.Set("user_role",u.Role); c.Set("username",u.Username); c.Set("current_user",u)
|
||||
}
|
||||
|
||||
// AuthMiddleware 校验 Session 或 API Token,并将当前用户写入上下文。
|
||||
// 上下文键:user_id、user_role、username、current_user。
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
setting, _ := settings.GetSettings()
|
||||
apiToken := ""
|
||||
|
||||
if setting.StartAPI {
|
||||
authHeader := c.Request.Header.Get("Authorization")
|
||||
parts := strings.SplitN(authHeader, "=", 2)
|
||||
if len(parts) == 2 && strings.TrimSpace(parts[0]) == "oneimg_token" {
|
||||
apiToken = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
if validateToken(setting, apiToken) {
|
||||
// API Token 视为超级管理员(通配权限)
|
||||
apiAdminUser := &models.User{
|
||||
ID: models.SuperAdminID,
|
||||
Role: models.RoleAdmin,
|
||||
Username: "api_admin",
|
||||
Permission: models.Permission{
|
||||
Codes: []string{"*"},
|
||||
Buckets: []int{},
|
||||
},
|
||||
}
|
||||
c.Set("user_id", models.SuperAdminID)
|
||||
c.Set("user_role", models.RoleAdmin)
|
||||
c.Set("username", "api_admin")
|
||||
c.Set("current_user", apiAdminUser)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
loggedIn := session.Get("logged_in")
|
||||
if (loggedIn == nil || loggedIn != true) && apiToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, AuthResponse{Code: 401, Message: "用户未登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userID := session.Get("user_id")
|
||||
userRole := session.Get("user_role")
|
||||
username := session.Get("username")
|
||||
if userID == nil || username == nil {
|
||||
c.JSON(http.StatusUnauthorized, AuthResponse{Code: 401, Message: "会话信息无效"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userIDValue, userIDOK := userID.(int)
|
||||
userRoleValue, userRoleOK := userRole.(int)
|
||||
usernameValue, usernameOK := username.(string)
|
||||
if !userIDOK || !userRoleOK || !usernameOK {
|
||||
c.JSON(http.StatusUnauthorized, AuthResponse{Code: 401, Message: "会话信息无效"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var currentUser models.User
|
||||
// 游客为虚拟账号;正式用户每次从库加载,使删除/改角色即时生效。
|
||||
if userRoleValue != models.RoleGuest {
|
||||
db := database.GetDB().DB
|
||||
if db == nil || db.Select("id", "role", "username", "permission").First(¤tUser, userIDValue).Error != nil {
|
||||
session.Clear()
|
||||
_ = session.Save()
|
||||
c.JSON(http.StatusUnauthorized, AuthResponse{Code: 401, Message: "用户不存在或已被禁用"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
userRoleValue = currentUser.Role
|
||||
usernameValue = currentUser.Username
|
||||
session.Set("user_role", userRoleValue)
|
||||
session.Set("username", usernameValue)
|
||||
} else {
|
||||
currentUser = models.User{
|
||||
ID: userIDValue,
|
||||
Role: models.RoleGuest,
|
||||
Username: usernameValue,
|
||||
}
|
||||
}
|
||||
|
||||
session.Set("logged_in", true)
|
||||
c.Set("user_id", userIDValue)
|
||||
c.Set("user_role", userRoleValue)
|
||||
c.Set("username", usernameValue)
|
||||
c.Set("current_user", ¤tUser)
|
||||
c.Next()
|
||||
}
|
||||
// Explicit Authorization is authoritative, including when API access is off.
|
||||
// An invalid credential MUST NOT silently fall back to an authenticated cookie.
|
||||
func AuthMiddleware() gin.HandlerFunc { return authenticate(false) }
|
||||
func OptionalAuthMiddleware() gin.HandlerFunc { return authenticate(true) }
|
||||
func authenticate(optional bool) gin.HandlerFunc {
|
||||
return authenticateDB(optional,func() *gorm.DB { db:=database.GetDB(); if db==nil{return nil};return db.DB },settings.GetSettings)
|
||||
}
|
||||
|
||||
// validateToken 校验 API Token(优先哈希比对,兼容明文遗留字段)。
|
||||
func validateToken(setting models.Settings, token string) bool {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
if secureconfig.CompareSecretHash(setting.APITokenHash, token) {
|
||||
return true
|
||||
}
|
||||
return setting.APIToken != "" && secureconfig.ConstantTimeEqual(setting.APIToken, token)
|
||||
// Dependencies are injectable for isolated SQLite/httptest regressions without
|
||||
// mutating the production database singleton or global settings cache.
|
||||
func authenticateDB(optional bool, getDB func() *gorm.DB, getSettings func()(models.Settings,error)) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
db:=getDB()
|
||||
if db==nil { rejectAuth(c,503,"认证服务暂不可用"); return }
|
||||
if headers,present:=c.Request.Header["Authorization"]; present {
|
||||
if len(headers)!=1 { rejectAuth(c,401,"API 凭据无效"); return }
|
||||
raw,ok:=parseAPIToken(headers[0]); if !ok { rejectAuth(c,401,"API 凭据无效"); return }
|
||||
setting,err:=getSettings(); if err!=nil { rejectAuth(c,503,"认证配置暂不可用"); return }
|
||||
token,err:=authsecurity.ValidateAPICredential(db,setting,raw)
|
||||
if err!=nil { rejectAuth(c,401,"API 凭据无效、已过期或 API 未开启"); return }
|
||||
scope:=tokenRouteScope(c.Request.Method,c.Request.URL.Path)
|
||||
if scope=="" || !authsecurity.HasScope(token,scope) { rejectAuth(c,403,"API 凭据无此操作权限"); return }
|
||||
var user models.User
|
||||
if db.First(&user,token.OwnerID).Error!=nil { rejectAuth(c,401,"API 凭据所属用户不存在"); return }
|
||||
// Controllers still enforce image ownership/bucket permissions. Tokens never
|
||||
// gain the superadmin permission bypass or settings/user/storage endpoints.
|
||||
user.Password=""; user.Role=models.RoleUser; user.Permission.Codes=[]string{"image:read","upload:write"}
|
||||
setCurrentUser(c,&user); c.Set("auth_method","api_token"); c.Set("api_credential",token)
|
||||
c.Next(); return
|
||||
}
|
||||
session:=sessions.Default(c)
|
||||
if session.Get("logged_in")!=true {
|
||||
if optional { c.Next(); return }; rejectAuth(c,401,"用户未登录或会话已过期"); return
|
||||
}
|
||||
id,ok:=session.Get("user_id").(int); if !ok || id==0 { rejectAuth(c,401,"会话信息无效"); return }
|
||||
var user models.User
|
||||
if id<0 {
|
||||
setting,err:=getSettings(); if err!=nil { rejectAuth(c,503,"认证配置暂不可用"); return }
|
||||
if !setting.Tourist { rejectAuth(c,401,"游客访问未开启"); return }
|
||||
credential,_:=c.Cookie(authsecurity.GuestCookie)
|
||||
var guest models.GuestIdentity
|
||||
if len(credential)!=43 || db.Where("id = ? AND credential_hash = ? AND expires_at > ?",-id,authsecurity.Digest(credential),time.Now()).First(&guest).Error!=nil {
|
||||
rejectAuth(c,401,"游客恢复凭据已失效,请重新进入游客模式"); return
|
||||
}
|
||||
user=models.User{ID:-guest.ID,Role:models.RoleGuest,Username:guest.OwnerKey}
|
||||
c.Set("guest_identity_verified",true)
|
||||
// OwnerKey is public. The durable HttpOnly bearer never enters context/JSON.
|
||||
c.Set("guest_owner_key",guest.OwnerKey)
|
||||
} else {
|
||||
if err:=db.First(&user,id).Error; err!=nil { rejectAuth(c,401,"用户不存在或已被禁用"); return }
|
||||
version,ok:=session.Get("auth_version").(uint64)
|
||||
if !ok || version!=user.AuthVersion { rejectAuth(c,401,"会话已撤销,请重新登录"); return }
|
||||
}
|
||||
user.Password=""; setCurrentUser(c,&user); c.Set("auth_method","session"); c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission 要求当前用户具备指定权限码;超级管理员与 "*" 直接放行。
|
||||
func RequirePermission(requiredCode string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userInterface, exists := c.Get("current_user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, AuthResponse{Code: 401, Message: "用户信息获取失败"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userInterface.(*models.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, AuthResponse{Code: 500, Message: "上下文类型错误"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user.ID == models.SuperAdminID {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
for _, code := range user.Permission.Codes {
|
||||
if code == "*" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user.Role == models.RoleUser {
|
||||
c.JSON(http.StatusForbidden, AuthResponse{
|
||||
Code: 403,
|
||||
Message: "无权访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Permission.HasPermission(requiredCode) {
|
||||
permName := models.GetPermissionName(requiredCode)
|
||||
c.JSON(http.StatusForbidden, AuthResponse{
|
||||
Code: 403,
|
||||
Message: "无操作权限,需要权限: [" + permName + "]",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
func parseAPIToken(header string) (string,bool) {
|
||||
// Preserve legacy header syntax and support standard Bearer without accepting
|
||||
// duplicate/comma-combined headers or empty/oversized credentials.
|
||||
var raw string
|
||||
if strings.HasPrefix(header,"oneimg_token=") { raw=strings.TrimPrefix(header,"oneimg_token=")
|
||||
} else if strings.HasPrefix(header,"Bearer ") { raw=strings.TrimPrefix(header,"Bearer ") } else { return "",false }
|
||||
if raw=="" || len(raw)>256 || strings.ContainsAny(raw," ,\t\r\n") { return "",false }; return raw,true
|
||||
}
|
||||
func tokenRouteScope(method,path string) string {
|
||||
if method==http.MethodGet {
|
||||
switch path { case "/api/user/status","/api/uploadConfig","/api/buckets/list","/api/tags","/api/images":return "image:read" }
|
||||
if strings.HasPrefix(path,"/api/images/") && !strings.Contains(strings.TrimPrefix(path,"/api/images/"),"/") { return "image:read" }
|
||||
}
|
||||
if method==http.MethodPost { switch path { case "/api/upload","/api/upload/images","/api/images/upload","/api/images/url":return "upload:write" } }
|
||||
return ""
|
||||
}
|
||||
func RequirePermission(required string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
u,ok:=GetCurrentUser(c); if !ok { rejectAuth(c,401,"用户信息获取失败"); return }
|
||||
if c.GetString("auth_method")=="api_token" {
|
||||
if !u.Permission.HasPermission(required) { rejectAuth(c,403,"API 凭据无此操作权限"); return }
|
||||
} else if u.ID==models.SuperAdminID || u.Permission.HasPermission("*") { c.Next(); return
|
||||
} else if !u.Permission.HasPermission(required) { rejectAuth(c,403,"无操作权限,需要权限: ["+models.GetPermissionName(required)+"]"); return }
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnlyMiddleware 仅允许管理员角色访问。
|
||||
func AdminOnlyMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.GetInt("user_role") != models.RoleAdmin {
|
||||
c.JSON(http.StatusForbidden, AuthResponse{Code: 403, Message: "无权访问"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuthMiddleware 可选登录:已登录则注入 user_id/username,未登录不拦截。
|
||||
func OptionalAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
loggedIn := session.Get("logged_in")
|
||||
if loggedIn != nil && loggedIn == true {
|
||||
userID := session.Get("user_id")
|
||||
username := session.Get("username")
|
||||
if userID != nil && username != nil {
|
||||
c.Set("user_id", userID)
|
||||
c.Set("username", username)
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUser 从上下文读取当前用户对象。
|
||||
func GetCurrentUser(c *gin.Context) (*models.User, bool) {
|
||||
userInterface, exists := c.Get("current_user")
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
user, ok := userInterface.(*models.User)
|
||||
return user, ok
|
||||
return func(c *gin.Context) { if c.GetString("auth_method")=="api_token" || c.GetInt("user_role")!=models.RoleAdmin { rejectAuth(c,403,"无权访问"); return }; c.Next() }
|
||||
}
|
||||
func GetCurrentUser(c *gin.Context) (*models.User,bool) { v,ok:=c.Get("current_user"); if !ok { return nil,false }; u,ok:=v.(*models.User); return u,ok && u!=nil }
|
||||
@@ -0,0 +1,34 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"oneimg/backend/config"
|
||||
)
|
||||
|
||||
// SecurityHeaders applies the SPA policy before handlers, so image delivery can
|
||||
// replace it with the stricter sandbox policy required for legacy SVG objects.
|
||||
func SecurityHeaders(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; connect-src 'self' https: http:; frame-src https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self' blob:")
|
||||
if cfg != nil && strings.HasPrefix(strings.ToLower(cfg.AppURL), "https://") {
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Pragma", "no-cache")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// MethodNotAllowed returns an explicit HTTP status, not a success-body error.
|
||||
func MethodNotAllowed(c *gin.Context) {
|
||||
c.JSON(http.StatusMethodNotAllowed, gin.H{"code": http.StatusMethodNotAllowed, "message": "请求方法不允许"})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Never trust arbitrary X-Forwarded-For. The reverse proxy must strip and
|
||||
// restore forwarding headers before it is explicitly configured as trusted.
|
||||
func TrustedClientIP(r *http.Request) string {
|
||||
host,_,err:=net.SplitHostPort(r.RemoteAddr); if err!=nil { return r.RemoteAddr }; return host
|
||||
}
|
||||
type rateEntry struct { Count int; Reset time.Time }
|
||||
type AuthLimiter struct { mu sync.Mutex; entries map[string]rateEntry; now func() time.Time; MaxEntries int }
|
||||
func NewAuthLimiter() *AuthLimiter { return &AuthLimiter{entries:make(map[string]rateEntry),now:time.Now,MaxEntries:8192} }
|
||||
func (l *AuthLimiter) allow(key string,limit int,window time.Duration) (bool,int) {
|
||||
l.mu.Lock(); defer l.mu.Unlock(); now:=l.now()
|
||||
entry,exists:=l.entries[key]
|
||||
if !exists || !now.Before(entry.Reset) {
|
||||
if len(l.entries)>=l.MaxEntries {
|
||||
for k,v:=range l.entries { if !now.Before(v.Reset) { delete(l.entries,k) } }
|
||||
}
|
||||
if _,exists=l.entries[key]; !exists && len(l.entries)>=l.MaxEntries { return false,60 }
|
||||
entry=rateEntry{Reset:now.Add(window)}
|
||||
}
|
||||
seconds:=int(entry.Reset.Sub(now).Seconds())+1
|
||||
if entry.Count>=limit { return false,seconds }
|
||||
entry.Count++; l.entries[key]=entry; return true,0
|
||||
}
|
||||
func limitedAuthPath(path string) bool {
|
||||
return path=="/api/login" || path=="/api/register" || strings.HasPrefix(path,"/api/auth/") || strings.HasPrefix(path,"/api/verify/")
|
||||
}
|
||||
func (l *AuthLimiter) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !limitedAuthPath(c.Request.URL.Path) { c.Next(); return }
|
||||
c.Header("Cache-Control","private, no-store")
|
||||
ip:=c.ClientIP()
|
||||
// One shared IP budget prevents rotating across login/SSO/PoW endpoints.
|
||||
if ok,wait:=l.allow("ip:"+ip,30,time.Minute); !ok { rateLimited(c,wait); return }
|
||||
if c.Request.Method==http.MethodPost {
|
||||
c.Request.Body=http.MaxBytesReader(c.Writer,c.Request.Body,32<<10)
|
||||
data,err:=io.ReadAll(c.Request.Body)
|
||||
if err!=nil { c.AbortWithStatusJSON(413,AuthResponse{Code:413,Message:"请求内容过大"}); return }
|
||||
c.Request.Body=io.NopCloser(bytes.NewReader(data))
|
||||
if c.Request.URL.Path=="/api/login" || c.Request.URL.Path=="/api/register" {
|
||||
var body struct { Username string `json:"username"` }
|
||||
if json.Unmarshal(data,&body)==nil && body.Username!="" {
|
||||
// Hash account names to bound key length; include an IP+account budget
|
||||
// and a global account budget to limit distributed online guessing.
|
||||
account:=authsecurity.Digest(strings.ToLower(strings.TrimSpace(body.Username)))
|
||||
if ok,wait:=l.allow("pair:"+ip+":"+account,5,time.Minute); !ok { rateLimited(c,wait); return }
|
||||
if ok,wait:=l.allow("account:"+account,20,5*time.Minute); !ok { rateLimited(c,wait); return }
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
func rateLimited(c *gin.Context,seconds int) {
|
||||
c.Header("Retry-After",strconv.Itoa(seconds)); c.AbortWithStatusJSON(429,gin.H{"code":429,"message":"尝试过于频繁,请稍后再试","retry_after":seconds})
|
||||
}
|
||||
|
||||
// Origin checks complement HttpOnly/SameSite cookies. Non-browser API clients
|
||||
// may omit Origin; requests with an explicit foreign Origin are always denied.
|
||||
func SameOriginWrites(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path,"/api/") || c.Request.Method==http.MethodGet || c.Request.Method==http.MethodHead || c.Request.Method==http.MethodOptions { c.Next(); return }
|
||||
if c.GetHeader("Sec-Fetch-Site")=="cross-site" { rejectAuth(c,403,"不允许跨站请求"); return }
|
||||
origin:=c.GetHeader("Origin")
|
||||
if origin!="" && !sameOrigin(origin,cfg.AppURL) { rejectAuth(c,403,"请求来源无效"); return }
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
func sameOrigin(a,b string) bool {
|
||||
x,err:=url.Parse(a); if err!=nil { return false }; y,err:=url.Parse(b); if err!=nil { return false }
|
||||
return x.Scheme!="" && x.User==nil && x.RawQuery=="" && x.Fragment=="" && (x.Path=="" || x.Path=="/") && strings.EqualFold(x.Scheme,y.Scheme) && strings.EqualFold(x.Host,y.Host)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"oneimg/backend/config"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func DeleteCurrentSession(c *gin.Context) error {
|
||||
session:=sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Options(sessions.Options{Path:"/",MaxAge:-1,HttpOnly:true,SameSite:http.SameSiteLaxMode,
|
||||
Secure:config.App!=nil && isHTTPS(config.App.AppURL)})
|
||||
// gin-contrib Clear marks an existing session dirty. An empty session still
|
||||
// needs saving so a stale/invalid browser cookie is explicitly expired.
|
||||
session.Set("_delete",true)
|
||||
return session.Save()
|
||||
}
|
||||
@@ -1,33 +1,135 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"oneimg/backend/config"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/memstore"
|
||||
"github.com/gin-gonic/gin"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/utils/authsecurity"
|
||||
gs "github.com/gorilla/sessions"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SessionStore 全局 Session 存储(进程内内存)。
|
||||
var SessionStore sessions.Store
|
||||
const SessionCookie = "oneimg-session"
|
||||
var ErrSessionRevoked = errors.New("session revoked; sign in again")
|
||||
|
||||
// SessionMiddleware 初始化 Cookie Session,并挂载到 Gin。
|
||||
type DBSessionStore struct {
|
||||
db *gorm.DB
|
||||
codecs []securecookie.Codec
|
||||
mu sync.RWMutex
|
||||
options gs.Options
|
||||
}
|
||||
|
||||
type sessionPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
Role int `json:"role"`
|
||||
Username string `json:"username"`
|
||||
Version uint64 `json:"version"`
|
||||
}
|
||||
|
||||
func NewDBSessionStore(db *gorm.DB, secret string, secure bool) *DBSessionStore {
|
||||
return &DBSessionStore{db:db,codecs:securecookie.CodecsFromPairs([]byte(secret)),
|
||||
options:gs.Options{Path:"/",MaxAge:86400,HttpOnly:true,Secure:secure,SameSite:http.SameSiteLaxMode}}
|
||||
}
|
||||
func (s *DBSessionStore) Options(o sessions.Options) {
|
||||
s.mu.Lock(); defer s.mu.Unlock(); s.options=*o.ToGorillaOptions()
|
||||
}
|
||||
func (s *DBSessionStore) Get(r *http.Request,name string) (*gs.Session,error) {
|
||||
return gs.GetRegistry(r).Get(s,name)
|
||||
}
|
||||
func (s *DBSessionStore) New(r *http.Request,name string) (*gs.Session,error) {
|
||||
sess:=gs.NewSession(s,name)
|
||||
s.mu.RLock(); opts:=s.options; s.mu.RUnlock(); sess.Options=&opts; sess.IsNew=true
|
||||
var state models.AuthState
|
||||
if err:=s.db.First(&state,1).Error; err!=nil { return sess,err }
|
||||
sess.Values["_epoch"]=state.Epoch
|
||||
cookie,err:=r.Cookie(name); if err!=nil { return sess,nil }
|
||||
var raw string
|
||||
if securecookie.DecodeMulti(name,cookie.Value,&raw,s.codecs...)!=nil || len(raw)!=43 { return sess,nil }
|
||||
var row models.AuthSession
|
||||
err=s.db.Where("id = ? AND expires_at > ? AND epoch = ?",authsecurity.Digest(raw),time.Now(),state.Epoch).First(&row).Error
|
||||
if errors.Is(err,gorm.ErrRecordNotFound) { return sess,nil }; if err!=nil { return sess,err }
|
||||
var p sessionPayload
|
||||
if json.Unmarshal(row.Values,&p)!=nil || p.UserID!=row.UserID || p.UserID==0 { return sess,nil }
|
||||
if p.UserID>0 {
|
||||
var u models.User
|
||||
if err=s.db.Select("id","auth_version").First(&u,p.UserID).Error; errors.Is(err,gorm.ErrRecordNotFound) { return sess,nil }
|
||||
if err!=nil { return sess,err }; if u.AuthVersion!=row.UserVersion { return sess,nil }
|
||||
} else {
|
||||
var g models.GuestIdentity
|
||||
if err=s.db.Where("id = ? AND expires_at > ?",-p.UserID,time.Now()).First(&g).Error; errors.Is(err,gorm.ErrRecordNotFound) { return sess,nil }
|
||||
if err!=nil { return sess,err }
|
||||
if g.OwnerKey!=p.Username || p.Role!=models.RoleGuest { return sess,nil }
|
||||
}
|
||||
sess.ID=raw; sess.IsNew=false
|
||||
sess.Values["user_id"]=p.UserID; sess.Values["user_role"]=p.Role
|
||||
sess.Values["username"]=p.Username; sess.Values["logged_in"]=true
|
||||
sess.Values["auth_version"]=p.Version
|
||||
return sess,nil
|
||||
}
|
||||
func (s *DBSessionStore) Save(r *http.Request,w http.ResponseWriter,sess *gs.Session) error {
|
||||
if sess.Options.MaxAge<0 || sess.Values["logged_in"]!=true {
|
||||
if sess.ID!="" { if err:=s.db.Where("id = ?",authsecurity.Digest(sess.ID)).Delete(&models.AuthSession{}).Error; err!=nil { return err } }
|
||||
opts:=*sess.Options; opts.MaxAge=-1
|
||||
http.SetCookie(w,gs.NewCookie(sess.Name(),"",&opts)); return nil
|
||||
}
|
||||
id,ok:=sess.Values["user_id"].(int); if !ok || id==0 { return ErrSessionRevoked }
|
||||
role,_:=sess.Values["user_role"].(int); username,_:=sess.Values["username"].(string)
|
||||
version,_:=sess.Values["auth_version"].(uint64); epoch,_:=sess.Values["_epoch"].(uint64)
|
||||
raw:=sess.ID
|
||||
rotate:=sess.Values["_rotate"]==true || raw==""
|
||||
if rotate { var err error; raw,err=authsecurity.RandomSecret(32); if err!=nil { return err } }
|
||||
payload,err:=json.Marshal(sessionPayload{UserID:id,Role:role,Username:username,Version:version}); if err!=nil { return err }
|
||||
encoded,err:=securecookie.EncodeMulti(sess.Name(),raw,s.codecs...); if err!=nil { return err }
|
||||
err=s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var state models.AuthState
|
||||
if err:=tx.Clauses(clause.Locking{Strength:"UPDATE"}).First(&state,1).Error; err!=nil { return err }
|
||||
if state.Epoch!=epoch { return ErrSessionRevoked }
|
||||
if id>0 {
|
||||
var user models.User
|
||||
if err:=tx.Clauses(clause.Locking{Strength:"UPDATE"}).Select("id","auth_version").First(&user,id).Error; err!=nil { return err }
|
||||
if user.AuthVersion!=version { return ErrSessionRevoked }
|
||||
}
|
||||
row:=models.AuthSession{ID:authsecurity.Digest(raw),UserID:id,UserVersion:version,Epoch:epoch,Values:payload,ExpiresAt:time.Now().Add(time.Duration(sess.Options.MaxAge)*time.Second)}
|
||||
if rotate {
|
||||
if sess.ID!="" {
|
||||
removed:=tx.Where("id = ?",authsecurity.Digest(sess.ID)).Delete(&models.AuthSession{})
|
||||
if removed.Error!=nil { return removed.Error }
|
||||
if !sess.IsNew && removed.RowsAffected!=1 && sess.Values["_reissue"]!=true { return ErrSessionRevoked }
|
||||
}
|
||||
return tx.Create(&row).Error
|
||||
}
|
||||
result:=tx.Model(&models.AuthSession{}).Where("id = ? AND epoch = ? AND expires_at > ?",row.ID,epoch,time.Now()).Updates(map[string]any{"values":payload,"expires_at":row.ExpiresAt})
|
||||
if result.Error!=nil { return result.Error }; if result.RowsAffected!=1 { return ErrSessionRevoked }; return nil
|
||||
})
|
||||
if err!=nil { return err }
|
||||
sess.ID=raw; sess.IsNew=false; delete(sess.Values,"_rotate"); delete(sess.Values,"_reissue")
|
||||
http.SetCookie(w,gs.NewCookie(sess.Name(),encoded,sess.Options)); return nil
|
||||
}
|
||||
|
||||
// Construct once during router setup, after database migrations, without a
|
||||
// process-global mutable store. Independent routers cannot overwrite each other.
|
||||
func SessionMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
SessionStore = memstore.NewStore([]byte(cfg.SessionSecret))
|
||||
SessionStore.Options(sessions.Options{
|
||||
MaxAge: 24 * 60 * 60, // 24 小时
|
||||
HttpOnly: true,
|
||||
Secure: isHTTPS(cfg.AppURL),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
return sessions.Sessions("oneimg-session", SessionStore)
|
||||
}
|
||||
|
||||
// isHTTPS 根据配置的 AppURL 判断是否应启用 Secure Cookie。
|
||||
func isHTTPS(rawURL string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(rawURL)), "https://")
|
||||
db:=database.GetDB()
|
||||
if db==nil || db.DB==nil { panic("session middleware: database not initialized") }
|
||||
if len(cfg.SessionSecret)<32 { panic("SESSION_SECRET must contain at least 32 characters") }
|
||||
if err:=authsecurity.EnsureState(db.DB); err!=nil { panic(err) }
|
||||
store:=NewDBSessionStore(db.DB,cfg.SessionSecret,isHTTPS(cfg.AppURL))
|
||||
middleware:=sessions.Sessions(SessionCookie,store)
|
||||
return func(c *gin.Context) {
|
||||
if _,err:=store.Get(c.Request,SessionCookie); err!=nil {
|
||||
c.AbortWithStatusJSON(503,AuthResponse{Code:503,Message:"会话服务暂不可用"}); return
|
||||
}
|
||||
middleware(c)
|
||||
}
|
||||
}
|
||||
func isHTTPS(rawURL string) bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(rawURL)),"https://") }
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// APICredential holds a one-way digest and explicit image/upload scopes. The
|
||||
// legacy settings token is enrolled once with a finite lifetime, never admin.
|
||||
type APICredential struct {
|
||||
ID string `gorm:"primaryKey;size:64" json:"-"`
|
||||
OwnerID int `json:"owner_id"`
|
||||
Scopes string `json:"scopes"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
Revoked bool `gorm:"not null;default:false" json:"revoked"`
|
||||
Legacy bool `json:"legacy"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthLoginEvent records an account sign-in independently of its session lifetime.
|
||||
// Store only an owner, a timestamp and the allowlisted authentication method:
|
||||
// never bearer cookies, session handles, passwords, SSO tokens or request metadata.
|
||||
// UserID deliberately is not a foreign key: guests are excluded by the recorder.
|
||||
type AuthLoginEvent struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"-"`
|
||||
UserID int `gorm:"not null;index:idx_auth_login_owner_created,priority:1" json:"-"`
|
||||
CreatedAt time.Time `gorm:"not null;index;index:idx_auth_login_owner_created,priority:2" json:"created_at"`
|
||||
Method string `gorm:"type:varchar(16);not null" json:"method"`
|
||||
}
|
||||
|
||||
// SQLite compares stored timestamps as text. Normalize offsets on every write
|
||||
// so local-time fixtures and UTC retention predicates share the same ordering.
|
||||
func (event *AuthLoginEvent) BeforeSave(_ *gorm.DB) error {
|
||||
if event.CreatedAt.IsZero() {
|
||||
event.CreatedAt = time.Now().UTC()
|
||||
} else {
|
||||
event.CreatedAt = event.CreatedAt.UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// AuthSession stores only a hash of the bearer cookie. Epochs prevent revoked
|
||||
// sessions from being resurrected by an in-flight request.
|
||||
type AuthSession struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
UserID int `gorm:"index"`
|
||||
UserVersion uint64
|
||||
Epoch uint64
|
||||
Values []byte
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AuthState provides an atomic, persistent global session revocation epoch.
|
||||
type AuthState struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement:false"`
|
||||
Epoch uint64 `gorm:"not null;default:0"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
func (bucket Buckets) CanStore(bytes uint64)bool{
|
||||
if bucket.Type=="default" || bucket.Type=="telegram" || bucket.Capacity==0{return true}
|
||||
return bucket.Usage<=bucket.Capacity && bytes<=bucket.Capacity-bucket.Usage
|
||||
}
|
||||
@@ -26,6 +26,8 @@ type R2Bucket struct {
|
||||
R2AccessKey string `json:"r2_access_key"`
|
||||
R2SecretKey string `json:"r2_secret_key"`
|
||||
R2Bucket string `json:"r2_bucket"`
|
||||
R2CDNDomain string `json:"r2_cdn_domain"`
|
||||
R2CDNMode string `json:"r2_cdn_mode"`
|
||||
}
|
||||
|
||||
// FTP 存储
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DirectUploadSettings intentionally contains only image-processing options.
|
||||
// Never persist Settings, credentials, signed URLs, or arbitrary bucket config here.
|
||||
type DirectUploadSettings struct {
|
||||
CompressImage bool
|
||||
SaveWebp bool
|
||||
Thumbnail bool
|
||||
EncryptedStorage bool
|
||||
WatermarkEnable bool
|
||||
WatermarkText string
|
||||
WatermarkPos string
|
||||
WatermarkSize int
|
||||
WatermarkColor string
|
||||
WatermarkOpac float64
|
||||
}
|
||||
|
||||
// DirectUploadTask is both the durable queue and the quota reservation ledger.
|
||||
// Staging objects are never Image rows. FinalKey is assigned before object writes.
|
||||
type DirectUploadTask struct {
|
||||
ID string `gorm:"size:36;primaryKey"`
|
||||
OwnerID int `gorm:"not null;uniqueIndex:idx_direct_owner_client,priority:1;index"`
|
||||
ClientID string `gorm:"size:36;not null;uniqueIndex:idx_direct_owner_client,priority:2"`
|
||||
BucketID int `gorm:"not null;index"`
|
||||
FolderId int `gorm:"not null;default:0;index"`
|
||||
// RequestedFolderId is immutable idempotency input; FolderId is reset when
|
||||
// a folder is deleted, without changing the originally submitted request.
|
||||
RequestedFolderId int `gorm:"not null;default:0"`
|
||||
Role int
|
||||
Filename string `gorm:"size:255"`
|
||||
Size int64
|
||||
ContentType string `gorm:"size:64"`
|
||||
SHA256 string `gorm:"size:64"`
|
||||
Tags []string `gorm:"type:text;serializer:json"`
|
||||
Settings DirectUploadSettings `gorm:"type:text;serializer:json"`
|
||||
Status string `gorm:"size:24;index"`
|
||||
Transport string `gorm:"size:16"`
|
||||
ThumbnailStatus string `gorm:"size:16;index"`
|
||||
ErrorCode string `gorm:"size:64"`
|
||||
Message string `gorm:"size:255"`
|
||||
Retryable bool
|
||||
Attempts int
|
||||
ThumbnailAttempts int
|
||||
ImageID int `gorm:"index"`
|
||||
DirectKey string `gorm:"size:512"`
|
||||
ProxyKey string `gorm:"size:512"`
|
||||
SourceKey string `gorm:"size:512"`
|
||||
FinalKey string `gorm:"size:512"`
|
||||
ThumbnailKey string `gorm:"size:512"`
|
||||
ReservedBytes int64 `gorm:"not null;default:0"`
|
||||
FinalSize int64
|
||||
MainSHA256 string `gorm:"size:64"`
|
||||
LastPutExpiresAt *time.Time
|
||||
SignCount int
|
||||
NextRetryAt *time.Time `gorm:"index"`
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
CleanedAt *time.Time `gorm:"index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Folder is an account-owned logical gallery grouping, never a storage path.
|
||||
// NameKey is a hash of the Unicode-normalized, case-folded name, so uniqueness
|
||||
// does not depend on the database's collation. ID zero means unfiled (no row).
|
||||
type Folder struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UserID int `json:"-" gorm:"not null;uniqueIndex:idx_folder_owner_name,priority:1;index"`
|
||||
Name string `json:"name" gorm:"size:64;not null"`
|
||||
Description string `json:"description" gorm:"type:text;not null;default:''"`
|
||||
NameKey string `json:"-" gorm:"size:64;not null;uniqueIndex:idx_folder_owner_name,priority:2"`
|
||||
Deleting bool `json:"deleting" gorm:"not null;default:false"`
|
||||
DeleteLease string `json:"-" gorm:"size:36"`
|
||||
DeleteLeaseUntil *time.Time `json:"-"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Positive sequence IDs here are exposed as NEGATIVE user IDs; they can never
|
||||
// collide with users.id. OwnerKey is public, not authentication material.
|
||||
type GuestIdentity struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement"`
|
||||
OwnerKey string `gorm:"uniqueIndex;size:36;not null"`
|
||||
CredentialHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
// EffectiveGuestStorageID preserves the legacy default when no guest override
|
||||
// is configured. It does not enable guest authentication or grant user access.
|
||||
func (s Settings) EffectiveGuestStorageID() int {
|
||||
if s.GuestStorage > 0 {
|
||||
return s.GuestStorage
|
||||
}
|
||||
return s.DefaultStorage
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import "time"
|
||||
|
||||
// 图片模型
|
||||
type Image struct {
|
||||
Deleting bool `json:"deleting" gorm:"not null;default:false;index"`
|
||||
Id int `json:"id" gorm:"type:integer;primaryKey;autoIncrement"`
|
||||
Url string `json:"url" gorm:"not null"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
@@ -18,7 +19,8 @@ type Image struct {
|
||||
// URL. Zero keeps the canonical/original bucket, which is the local bucket
|
||||
// for images created in multi-storage mode.
|
||||
AccessBucketId int `json:"access_bucket_id" gorm:"column:access_bucket_id;not null;default:0;index"`
|
||||
UserId int `json:"user_id" gorm:"not null;default:1"`
|
||||
UserId int `json:"user_id" gorm:"not null;default:1;index:idx_images_owner_folder,priority:1"`
|
||||
FolderId int `json:"folder_id" gorm:"not null;default:0;index;index:idx_images_owner_folder,priority:2"`
|
||||
MD5 string `json:"md5"`
|
||||
UUID string `json:"uuid" gorm:"not null;default:'00000000-0000-0000-0000-000000000000'"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -7,6 +7,7 @@ const (
|
||||
ImageStorageStatusUploading = "uploading"
|
||||
ImageStorageStatusSuccess = "success"
|
||||
ImageStorageStatusFailed = "failed"
|
||||
ImageStorageStatusDeleting = "deleting"
|
||||
)
|
||||
|
||||
// ImageStorage records one physical copy of an image in a storage bucket.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
// An absent row preserves legacy role behavior. Custom [] is an intentional
|
||||
// empty inherited pool, not a fallback to legacy defaults.
|
||||
type RoleStoragePolicy struct {
|
||||
Role int `json:"role" gorm:"primaryKey;autoIncrement:false"`
|
||||
Mode string `json:"mode" gorm:"not null"`
|
||||
BucketIDs []int `json:"bucket_ids" gorm:"type:text;serializer:json;not null"`
|
||||
}
|
||||
+40
-30
@@ -7,30 +7,36 @@ import (
|
||||
// Settings 系统配置模型(全局唯一配置)
|
||||
// 注意:该表应只有一条记录(ID=1),所有配置项存储在同一条记录中
|
||||
type Settings struct {
|
||||
ID int `gorm:"type:integer;primarykey;column:id;autoIncrement" json:"id"`
|
||||
CompressImage bool `gorm:"column:compress_image;default:false" json:"compress_image"` // 是否压缩图片(默认不压缩)
|
||||
SaveWebp bool `gorm:"column:save_webp;default:true" json:"save_webp"` // 是否保存webp格式(默认保存)
|
||||
Thumbnail bool `gorm:"column:thumbnail;default:true" json:"thumbnail"` // 是否生成缩略图(默认生成)
|
||||
Tourist bool `gorm:"column:tourist;default:false" json:"tourist"` // 是否允许游客上传(默认允许)
|
||||
TGNotice bool `gorm:"column:tg_notice;default:false" json:"tg_notice"` // 是否启用TG通知(默认关闭)
|
||||
PowVerify bool `gorm:"column:pow_verify;default:false" json:"pow_verify"` // 是否启用POW验证(默认关闭,旧版开关,兼容用)
|
||||
VerifyMethod string `gorm:"column:verify_method;default:''" json:"verify_method"` // 人机验证方式:''|none|pow|turnstile|cappow(空则按 pow_verify 兼容解析)
|
||||
TurnstileSiteKey string `gorm:"column:turnstile_site_key;default:''" json:"turnstile_site_key"` // Cloudflare Turnstile 站点公钥
|
||||
TurnstileSecret string `gorm:"column:turnstile_secret_key;default:''" json:"turnstile_secret_key"` // Cloudflare Turnstile 密钥(敏感,加密存储)
|
||||
CappowDifficulty int `gorm:"column:cappow_difficulty;default:4" json:"cappow_difficulty"` // cap-pow 本地难度(目标 hex 前缀长度,1-8)
|
||||
CloudflareAPIToken string `gorm:"column:cloudflare_api_token;default:''" json:"cloudflare_api_token"` // Cloudflare API Token(Turnstile:Read,用于校验公钥,敏感,加密存储)
|
||||
CloudflareAccountID string `gorm:"column:cloudflare_account_id;default:''" json:"cloudflare_account_id"` // Cloudflare 账号 ID(用于校验公钥)
|
||||
TGBotToken string `gorm:"column:tg_bot_token;default:''" json:"tg_bot_token"` // TG机器人Token
|
||||
TGReceivers string `gorm:"column:tg_receivers;default:''" json:"tg_receivers"` // TG接收者(多个用逗号分隔)
|
||||
TGNoticeText string `gorm:"column:tg_notice_text;default:''" json:"tg_notice_text"` // TG通知文本
|
||||
StartAPI bool `gorm:"column:start_api;default:false" json:"start_api"` // 是否启用API(默认关闭)
|
||||
APIToken string `gorm:"column:api_token;default:''" json:"api_token"` // 兼容旧字段
|
||||
RandomGraph bool `gorm:"column:random_graph;default:false" json:"random_graph"` // 是否启用随机图(默认关闭)
|
||||
APITokenHash string `gorm:"column:api_token_hash;default:''" json:"-"` // API Token哈希
|
||||
SaveOriginalName bool `gorm:"column:save_original_name;default:false" json:"save_original_name"` // 是否保存原文件名(默认不保存)
|
||||
StartRegister bool `gorm:"column:start_register;default:false" json:"start_register"` // 是否启用注册(默认关闭)
|
||||
ID int `gorm:"type:integer;primarykey;column:id;autoIncrement:false;default:1" json:"id"`
|
||||
CompressImage bool `gorm:"column:compress_image;default:false" json:"compress_image"` // 是否压缩图片(默认不压缩)
|
||||
SaveWebp bool `gorm:"column:save_webp;default:true" json:"save_webp"` // 是否保存webp格式(默认保存)
|
||||
Thumbnail bool `gorm:"column:thumbnail;default:true" json:"thumbnail"` // 是否生成缩略图(默认生成)
|
||||
Tourist bool `gorm:"column:tourist;default:false" json:"tourist"` // 是否允许游客上传(默认允许)
|
||||
TGNotice bool `gorm:"column:tg_notice;default:false" json:"tg_notice"` // 是否启用TG通知(默认关闭)
|
||||
PowVerify bool `gorm:"column:pow_verify;default:false" json:"pow_verify"` // 是否启用POW验证(默认关闭,旧版开关,兼容用)
|
||||
PowVerifyURL string `gorm:"column:pow_verify_url;default:'https://cha.eta.im/api/validate'" json:"pow_verify_url"`
|
||||
PowScriptURL string `gorm:"column:pow_script_url;default:'https://cha.eta.im/static/js/pow.min.js'" json:"pow_script_url"`
|
||||
PowWidgetURL string `gorm:"column:pow_widget_url;default:'https://cha.eta.im/'" json:"pow_widget_url"`
|
||||
PowVerifyTimeoutSeconds int `gorm:"column:pow_verify_timeout_seconds;default:5" json:"pow_verify_timeout_seconds"`
|
||||
PowLocalFallback bool `gorm:"column:pow_local_fallback;default:false" json:"pow_local_fallback"` // Explicitly use local cap-pow instead of the legacy provider.
|
||||
VerifyMethod string `gorm:"column:verify_method;default:''" json:"verify_method"` // 人机验证方式:''|none|pow|turnstile|cappow(空则按 pow_verify 兼容解析)
|
||||
TurnstileSiteKey string `gorm:"column:turnstile_site_key;default:''" json:"turnstile_site_key"` // Cloudflare Turnstile 站点公钥
|
||||
TurnstileSecret string `gorm:"column:turnstile_secret_key;default:''" json:"turnstile_secret_key"` // Cloudflare Turnstile 密钥(敏感,加密存储)
|
||||
CappowDifficulty int `gorm:"column:cappow_difficulty;default:4" json:"cappow_difficulty"` // cap-pow 本地难度(目标 hex 前缀长度,1-8)
|
||||
CloudflareAPIToken string `gorm:"column:cloudflare_api_token;default:''" json:"cloudflare_api_token"` // Cloudflare API Token(Turnstile:Read,用于校验公钥,敏感,加密存储)
|
||||
CloudflareAccountID string `gorm:"column:cloudflare_account_id;default:''" json:"cloudflare_account_id"` // Cloudflare 账号 ID(用于校验公钥)
|
||||
TGBotToken string `gorm:"column:tg_bot_token;default:''" json:"tg_bot_token"` // TG机器人Token
|
||||
TGReceivers string `gorm:"column:tg_receivers;default:''" json:"tg_receivers"` // TG接收者(多个用逗号分隔)
|
||||
TGNoticeText string `gorm:"column:tg_notice_text;default:''" json:"tg_notice_text"` // TG通知文本
|
||||
StartAPI bool `gorm:"column:start_api;default:false" json:"start_api"` // 是否启用API(默认关闭)
|
||||
APIToken string `gorm:"column:api_token;default:''" json:"api_token"` // 兼容旧字段
|
||||
RandomGraph bool `gorm:"column:random_graph;default:false" json:"random_graph"` // 是否启用随机图(默认关闭)
|
||||
APITokenHash string `gorm:"column:api_token_hash;default:''" json:"-"` // API Token哈希
|
||||
SaveOriginalName bool `gorm:"column:save_original_name;default:false" json:"save_original_name"` // 是否保存原文件名(默认不保存)
|
||||
StartRegister bool `gorm:"column:start_register;default:false" json:"start_register"` // 是否启用注册(默认关闭)
|
||||
|
||||
// 默认存储
|
||||
// 默认存储;游客 0 沿用系统默认,显式配置不改变游客登录开关。
|
||||
GuestStorage int `gorm:"column:guest_storage;default:0" json:"guest_storage"`
|
||||
DefaultStorage int `gorm:"column:default_storage;default:1" json:"default_storage"` // 单存储模式下的默认存储
|
||||
MultiStorageSync bool `gorm:"column:multi_storage_sync;default:false" json:"multi_storage_sync"` // 是否启用本机落盘后的多存储后台同步
|
||||
EncryptedStorage bool `gorm:"column:encrypted_storage;default:false" json:"encrypted_storage"` // 是否加密新写入到各存储源的图片文件
|
||||
@@ -54,10 +60,13 @@ type Settings struct {
|
||||
CASSuperAdminUsername string `gorm:"column:cas_super_admin_username;default:''" json:"cas_super_admin_username"`
|
||||
|
||||
// 默认上传配置
|
||||
MaxFileSize int `gorm:"column:max_file_size;default:10485760" json:"max_file_size"` // 文件最大上传大小
|
||||
AllowedTypes string `gorm:"column:allowed_types;default:'image/jpeg,image/png,image/gif,image/webp,image/svg+xml'" json:"allowed_types"`
|
||||
DefaultPath string `gorm:"column:default_path;default:'/uploads/{year}/{moon}'" json:"default_path"` // 默认上传路径,魔法变量 {year} 年 {month} 月 {day} 日 {hour} 小时 {minute} 分钟 {random} 随机 {uuid} UUID {role} 角色(1 为管理员, 2 为游客)
|
||||
FileName string `gorm:"column:file_name;default:'{random}'" json:"file_name"` // 上传文件名称,魔法变量 {random} 随机数 {year} 年 {month} 月 {day} 日 {hour} 小时 {minute} 分钟 {second} 秒
|
||||
MaxUploadFiles int `gorm:"column:max_upload_files;default:10" json:"max_upload_files"`
|
||||
TagMaxLength int `gorm:"column:tag_max_length;default:10" json:"tag_max_length"`
|
||||
RandomImageLimit int `gorm:"column:random_image_limit;default:20" json:"random_image_limit"`
|
||||
MaxFileSize int `gorm:"column:max_file_size;default:10485760" json:"max_file_size"` // 文件最大上传大小
|
||||
AllowedTypes string `gorm:"column:allowed_types;default:'image/jpeg,image/png,image/gif,image/webp'" json:"allowed_types"`
|
||||
DefaultPath string `gorm:"column:default_path;default:'/uploads/{year}/{moon}'" json:"default_path"` // 默认上传路径,魔法变量 {year} 年 {month} 月 {day} 日 {hour} 小时 {minute} 分钟 {random} 随机 {uuid} UUID {role} 角色(1 为管理员, 2 为游客)
|
||||
FileName string `gorm:"column:file_name;default:'{random}'" json:"file_name"` // 上传文件名称,魔法变量 {random} 随机数 {year} 年 {month} 月 {day} 日 {hour} 小时 {minute} 分钟 {second} 秒
|
||||
|
||||
// 图片直链设置
|
||||
PublicImageDomain string `gorm:"column:public_image_domain;default:''" json:"public_image_domain"` // 图片直链域名(用于非本地存储直接访问)
|
||||
@@ -72,6 +81,7 @@ type Settings struct {
|
||||
|
||||
// 来源白名单设置
|
||||
RefererWhiteEnable bool `gorm:"column:referer_white_enable;default:false" json:"referer_white_enable"` // 是否启用白名单
|
||||
RefererAllowEmpty bool `gorm:"column:referer_allow_empty;default:true" json:"referer_allow_empty"` // 保持直接公开链接兼容;关闭后要求 Referer
|
||||
RefererWhiteList string `gorm:"column:referer_white_list;default:''" json:"referer_white_list"` // 白名单(多个用逗号分隔)
|
||||
|
||||
// SEO 设置
|
||||
@@ -85,10 +95,10 @@ type Settings struct {
|
||||
|
||||
// 人机验证方式常量。
|
||||
const (
|
||||
VerifyMethodNone = "none"
|
||||
VerifyMethodPOW = "pow"
|
||||
VerifyMethodNone = "none"
|
||||
VerifyMethodPOW = "pow"
|
||||
VerifyMethodTurnstile = "turnstile"
|
||||
VerifyMethodCappow = "cappow"
|
||||
VerifyMethodCappow = "cappow"
|
||||
)
|
||||
|
||||
// EffectiveVerifyMethod 返回实际生效的人机验证方式。
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package models
|
||||
|
||||
type Tags struct {
|
||||
Id int `json:"id" gorm:"type:integer;primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"not null;default:'';uniqueIndex:name;size:50"`
|
||||
Id int `json:"id" gorm:"type:integer;primaryKey;autoIncrement"`
|
||||
// Keep the legacy unique constraint as well as the named index: GORM
|
||||
// must not try dropping a guessed constraint name on upgraded PostgreSQL.
|
||||
Name string `json:"name" gorm:"not null;default:'';unique;uniqueIndex:name;size:50"`
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type User struct {
|
||||
Permission Permission `json:"permission" gorm:"type:json"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
AuthVersion uint64 `json:"-" gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
// 角色与超级管理员约定。
|
||||
@@ -39,7 +40,8 @@ var AllPermissionMap = map[string]string{
|
||||
"tag:delete": "删除Tag",
|
||||
"tag:update": "编辑Tag",
|
||||
|
||||
"setting:upload": "上传与存储",
|
||||
"setting:list": "查看设置",
|
||||
"setting:upload": "上传与存储",
|
||||
"setting:image": "图片处理",
|
||||
"setting:security": "安全与登录",
|
||||
"setting:notification": "通知",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ORM writes and database constraints agree; migrations normalize legacy IDs
|
||||
// before adding constraints. Zero ID means the global singleton, never a new row.
|
||||
func (s *Settings) BeforeSave(tx *gorm.DB) error {
|
||||
if s.ID == 0 {
|
||||
s.ID = 1
|
||||
}
|
||||
if s.ID != 1 {
|
||||
return fmt.Errorf("settings ID must be 1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
// SetupRoutes 注册中间件、API 与 SPA 回退路由。
|
||||
func SetupRoutes(frontendFS embed.FS) *gin.Engine {
|
||||
func SetupRoutes(frontendFS fs.FS) *gin.Engine {
|
||||
cfg := config.App
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -26,6 +27,22 @@ func SetupRoutes(frontendFS embed.FS) *gin.Engine {
|
||||
"/api/auth/oidc/callback",
|
||||
"/api/auth/cas/callback",
|
||||
}}))
|
||||
r.Use(middlewares.SecurityHeaders(config.App))
|
||||
r.HandleMethodNotAllowed = true
|
||||
r.NoMethod(middlewares.MethodNotAllowed)
|
||||
// Only explicit proxy networks may supply forwarded addresses. Empty disables
|
||||
// Gin's insecure default trust of every proxy/client-supplied X-Forwarded-For.
|
||||
trusted := []string{}
|
||||
for _, entry := range strings.Split(os.Getenv("TRUSTED_PROXIES"), ",") {
|
||||
if entry = strings.TrimSpace(entry); entry != "" {
|
||||
trusted = append(trusted, entry)
|
||||
}
|
||||
}
|
||||
if err := r.SetTrustedProxies(trusted); err != nil {
|
||||
log.Fatalf("无效 TRUSTED_PROXIES: %v", err)
|
||||
}
|
||||
r.Use(middlewares.NewAuthLimiter().Middleware(), middlewares.AuthBodyLimitMiddleware(64<<10), middlewares.SameOriginWrites(config.App))
|
||||
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middlewares.ConfigMiddleware(cfg))
|
||||
r.Use(middlewares.SessionMiddleware(cfg))
|
||||
@@ -56,6 +73,14 @@ func SetupRoutes(frontendFS embed.FS) *gin.Engine {
|
||||
assetsFS, _ := fs.Sub(distFS, "assets")
|
||||
r.StaticFS("/assets", http.FS(assetsFS))
|
||||
r.StaticFile("/favicon.ico", "./frontend/dist/favicon.ico")
|
||||
r.GET("/theme-init.js", func(c *gin.Context) {
|
||||
content, err := fs.ReadFile(distFS, "theme-init.js")
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/javascript; charset=utf-8", content)
|
||||
})
|
||||
|
||||
// cap-pow 自托管验证组件(cap-widget JS + WASM)
|
||||
capFS, _ := fs.Sub(distFS, "cap")
|
||||
@@ -66,8 +91,7 @@ func SetupRoutes(frontendFS embed.FS) *gin.Engine {
|
||||
// 公开接口
|
||||
api.POST("/login", controllers.Login)
|
||||
api.POST("/register", controllers.Register)
|
||||
api.POST("/logout", controllers.Logout)
|
||||
api.GET("/logout", controllers.Logout)
|
||||
api.POST("/logout", middlewares.OptionalAuthMiddleware(), controllers.Logout)
|
||||
api.GET("/settings/login", controllers.GetLoginSettings)
|
||||
api.GET("/settings/seo", controllers.GetSEOSettings)
|
||||
api.POST("/verify/cappow/challenge", controllers.CapPowChallenge)
|
||||
@@ -88,14 +112,29 @@ func SetupRoutes(frontendFS embed.FS) *gin.Engine {
|
||||
auth.GET("/stats/dashboard", controllers.GetDashboardStats)
|
||||
auth.GET("/stats/images", controllers.GetImageStats)
|
||||
|
||||
auth.GET("/folders", controllers.GetFolders)
|
||||
auth.POST("/folders", controllers.CreateFolder)
|
||||
auth.PUT("/folders/:id", controllers.UpdateFolder)
|
||||
auth.DELETE("/folders/:id", controllers.DeleteFolder)
|
||||
auth.PUT("/images/folder", controllers.MoveImagesFolder)
|
||||
auth.GET("/tags", controllers.GetTags)
|
||||
auth.GET("/buckets/list", controllers.GetBucketsList)
|
||||
|
||||
// 图片
|
||||
auth.POST("/upload", controllers.UploadImage)
|
||||
auth.POST("/upload/images", controllers.UploadImages)
|
||||
direct := auth.Group("/uploads/direct", controllers.DirectUploadGuard())
|
||||
direct.POST("", controllers.CreateDirectUpload)
|
||||
direct.GET("", controllers.ListDirectUploads)
|
||||
direct.GET("/:id", controllers.GetDirectUpload)
|
||||
direct.POST("/:id/sign", controllers.SignDirectUpload)
|
||||
direct.POST("/:id/complete", controllers.CompleteDirectUpload)
|
||||
direct.POST("/:id/fallback", controllers.FallbackDirectUpload)
|
||||
direct.POST("/:id/retry", controllers.RetryDirectUpload)
|
||||
direct.DELETE("/:id", controllers.CancelDirectUpload)
|
||||
auth.DELETE("/images/:id", controllers.DeleteImage)
|
||||
auth.GET("/images", controllers.GetImageList)
|
||||
auth.GET("/admin/images", middlewares.AdminOnlyMiddleware(), controllers.GetManagedImageList)
|
||||
auth.GET("/images/:id", controllers.GetImageDetail)
|
||||
auth.POST("/images/tag", controllers.AddImageTag)
|
||||
auth.DELETE("/images/tag", controllers.DeleteImageTag)
|
||||
@@ -119,11 +158,18 @@ func SetupRoutes(frontendFS embed.FS) *gin.Engine {
|
||||
auth.DELETE("/buckets/:id", middlewares.RequirePermission("storage:delete"), controllers.DeleteBuckets)
|
||||
|
||||
// 账户
|
||||
auth.DELETE("/account/sessions/:id", controllers.RevokeAccountSession)
|
||||
auth.POST("/account/change", controllers.ChangeAccountInfo)
|
||||
auth.GET("/account/sessions", controllers.ListSessions)
|
||||
auth.GET("/account/login-history", controllers.ListAccountLoginHistory)
|
||||
auth.POST("/account/sessions/revoke", controllers.RevokeOwnSessions)
|
||||
auth.POST("/sessions/clear", middlewares.RequirePermission("setting:security"), controllers.ClearAllSessions)
|
||||
|
||||
// 用户管理
|
||||
auth.GET("/users", middlewares.AdminOnlyMiddleware(), controllers.GetUsers)
|
||||
auth.GET("/admin/access-policy", middlewares.AdminOnlyMiddleware(), controllers.GetAccessPolicy)
|
||||
auth.GET("/admin/storage-assignments", middlewares.AdminOnlyMiddleware(), controllers.GetStorageAssignments)
|
||||
auth.PUT("/admin/storage-assignments/:role", middlewares.AdminOnlyMiddleware(), controllers.UpdateStorageAssignment)
|
||||
auth.POST("/users/Add", middlewares.RequirePermission("user:create"), controllers.CreateUser)
|
||||
auth.DELETE("/users/:id", middlewares.RequirePermission("user:delete"), controllers.DeleteUser)
|
||||
auth.POST("/users/updateRole", middlewares.RequirePermission("user:role:update"), controllers.UpdateUserRole)
|
||||
@@ -131,7 +177,10 @@ func SetupRoutes(frontendFS embed.FS) *gin.Engine {
|
||||
auth.POST("/users/updatePermission/:id", middlewares.RequirePermission("user:permission:update"), controllers.UpdateUserPermission)
|
||||
|
||||
// 系统设置
|
||||
auth.Any("/settings/get", controllers.GetSettings)
|
||||
auth.GET("/settings/get", middlewares.RequirePermission("setting:list"), controllers.GetSettings)
|
||||
auth.POST("/settings/get", middlewares.RequirePermission("setting:list"), controllers.GetSettings)
|
||||
auth.POST("/settings/regenerate", middlewares.RequirePermission("setting:api"), controllers.RegenerateAPIToken)
|
||||
auth.POST("/settings/renew_session", controllers.RenewSession)
|
||||
auth.POST("/settings/update", controllers.UpdateSettings)
|
||||
auth.GET("/settings/randomGraph", middlewares.RequirePermission("setting:api"), controllers.GetRandomGraph)
|
||||
auth.POST("/settings/randomGraph", middlewares.RequirePermission("setting:api"), controllers.SetRandomGraph)
|
||||
@@ -147,6 +196,16 @@ func SetupRoutes(frontendFS embed.FS) *gin.Engine {
|
||||
if controllers.ImageProxy(c) {
|
||||
return
|
||||
}
|
||||
// A missing image must not masquerade as an HTML SPA success response.
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "图片不存在"})
|
||||
return
|
||||
}
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
c.Header("Allow", "GET, HEAD")
|
||||
middlewares.MethodNotAllowed(c)
|
||||
return
|
||||
}
|
||||
indexContent, err := fs.ReadFile(distFS, "index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载前端页面失败:%s", err)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DirectUploadMaxBytes int64 = 32 << 20
|
||||
|
||||
const directPutTTL = 15 * time.Minute
|
||||
|
||||
const directTaskTTL = 24 * time.Hour
|
||||
|
||||
const directMaxAttempts = 3
|
||||
|
||||
var directMu sync.Mutex
|
||||
|
||||
var directUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
var directHash = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// DirectUploadError is safe to return to clients; no SDK error or signed URL is exposed.
|
||||
type DirectUploadError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
func (e *DirectUploadError) Error() string { return e.Message }
|
||||
|
||||
func directError(status int, code, message string, retry bool) *DirectUploadError {
|
||||
return &DirectUploadError{status, code, message, retry}
|
||||
}
|
||||
|
||||
func directDBError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var folderErr *GalleryFolderError
|
||||
if errors.As(err, &folderErr) {
|
||||
return directError(folderErr.Status, folderErr.Code, folderErr.Message, false)
|
||||
}
|
||||
var e *DirectUploadError
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
return directError(503, "database_unavailable", "上传任务暂时不可用,请稍后重试", true)
|
||||
}
|
||||
|
||||
type CreateDirectUploadRequest struct {
|
||||
ClientID string `json:"client_id"`
|
||||
BucketID int `json:"bucket_id"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderID int `json:"folder_id"`
|
||||
}
|
||||
|
||||
type DirectUploadInstruction struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type DirectUploadImage struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
Filename string `json:"filename"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Storage string `json:"storage"`
|
||||
BucketID int `json:"bucket_id"`
|
||||
FolderID int `json:"folder_id"`
|
||||
}
|
||||
|
||||
type DirectUploadResponse struct {
|
||||
ID string `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
FolderID int `json:"folder_id"`
|
||||
BucketID int `json:"bucket_id"`
|
||||
Filename string `json:"filename"`
|
||||
Status string `json:"status"`
|
||||
Transport string `json:"transport"`
|
||||
ThumbnailStatus string `json:"thumbnail_status"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Attempts int `json:"attempts"`
|
||||
ThumbnailAttempts int `json:"thumbnail_attempts"`
|
||||
ImageID int `json:"image_id"`
|
||||
Image *DirectUploadImage `json:"image"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
NextRetryAt *time.Time `json:"next_retry_at"`
|
||||
Upload *DirectUploadInstruction `json:"upload,omitempty"`
|
||||
}
|
||||
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user