feat: add gallery folders, role storage controls and secure upload flows

This commit is contained in:
hicocos committed 2026-10-09 14:49:42 +08:00
1 parent d629f0fc07
commit ef258ade7d
229 files changed
+18916 -11546

No files matched your search

+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+70
View File
@@ -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
}
+14
View File
@@ -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
View File
@@ -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("✅ 配置初始化完成")
+86
View File
@@ -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
}
+113
View File
@@ -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
View File
@@ -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)
}
}
+46
View File
@@ -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))
}
+34
View File
@@ -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))
}
+6
View File
@@ -0,0 +1,6 @@
package controllers
func bucketRemaining(capacity,usage uint64)uint64{
if usage>=capacity {return 0}
return capacity-usage
}
+385
View File
@@ -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, &params); 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, &params); 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))
}
+191
View File
@@ -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))
}
+51 -8
View File
@@ -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
}
+55
View File
@@ -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])
}
+105
View File
@@ -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
View File
@@ -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, &params); 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, &params); 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
}
+19 -224
View File
@@ -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)
}
+130
View File
@@ -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)
}
+9
View File
@@ -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
+295
View File
@@ -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 ""
}
+217
View File
@@ -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,
})
}
+124
View File
@@ -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())
}
+53
View File
@@ -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))
}
+197
View File
@@ -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[:])
}
+285
View File
@@ -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
}
+183
View File
@@ -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}))
}
+17
View File
@@ -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))
}
+41 -38
View File
@@ -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
}
+91 -52
View File
@@ -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 {
+50
View File
@@ -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)
}
+36
View File
@@ -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
}
+30
View File
@@ -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
}
+113
View File
@@ -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
}
+294
View File
@@ -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))
}
+11 -14
View File
@@ -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 {
+265
View File
@@ -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),
})
}
+73 -532
View File
@@ -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),
})
}
+80 -75
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+42
View File
@@ -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, "文件读取失败"))
}
}
}
+135
View File
@@ -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
}
+9 -6
View File
@@ -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
}
-877
View File
@@ -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 ""
}
+97
View File
@@ -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
}
+125
View File
@@ -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 ""
}
+135
View File
@@ -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,
},
))
}
+55
View File
@@ -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)
}
}
+141
View File
@@ -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))
}
+291
View File
@@ -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
}
+269
View File
@@ -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
View File
@@ -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})
}
+118
View File
@@ -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))
}
+16 -49
View File
@@ -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)
}
}
+28 -7
View File
@@ -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
}
+57
View File
@@ -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
}
+47
View File
@@ -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 } }
}
+56
View File
@@ -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
}
+14 -19
View File
@@ -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"),
}})
}
+91 -38
View File
@@ -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(&current, 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(&current).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)
}
+15 -6
View File
@@ -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
}
+67 -38
View File
@@ -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)
}
+196
View File
@@ -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
}
+55
View File
@@ -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
}
+76
View File
@@ -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
})
}
+1
View File
@@ -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"`
+18
View File
@@ -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()
}
}
+94 -201
View File
@@ -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(&currentUser, 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", &currentUser)
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 }
+34
View File
@@ -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": "请求方法不允许"})
}
+88
View File
@@ -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)
}
+19
View File
@@ -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()
}
+127 -25
View File
@@ -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://") }
+16
View File
@@ -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"`
}
+29
View File
@@ -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
}
+21
View File
@@ -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"`
}
+6
View File
@@ -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
}
+2
View File
@@ -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 存储
+62
View File
@@ -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
}
+23
View File
@@ -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:"-"`
}
+13
View File
@@ -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
}
+10
View File
@@ -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
}
+3 -1
View File
@@ -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"`
+1
View File
@@ -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.
+9
View File
@@ -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
View File
@@ -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 返回实际生效的人机验证方式。
+4 -2
View File
@@ -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"`
}
+3 -1
View File
@@ -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": "通知",
+18
View File
@@ -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
}
+64 -5
View File
@@ -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)
+105
View File
@@ -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