fix storage compatibility and gitea release reporting
This commit is contained in:
1 parent
ed0e2f4ee1
commit
a9ee768a6b
29 files changed
+573
-126
No files matched your search
@@ -16,6 +16,12 @@
|
||||
# 公网部署必须改成实际地址,否则返回的图片链接不可用。
|
||||
APP_URL=https://img.example.com
|
||||
|
||||
# 账户页管理员版本信息来源(Gitea 仓库)
|
||||
GITEA_URL=https://git.bytevibe.dev
|
||||
GITEA_REPOSITORY=gary/oneimg
|
||||
# 私有 Gitea 仓库可填只读令牌;公开仓库留空
|
||||
GITEA_TOKEN=
|
||||
|
||||
# 监听端口,改这里时 compose 的端口映射会同步变化。
|
||||
SERVER_PORT=8080
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: 构建并推送 Gitea 镜像
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: ${{ vars.GITEA_REGISTRY || 'git.bytevibe.dev' }}
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: DockerBuildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 登录 Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: 编译并推送
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
@@ -1,32 +0,0 @@
|
||||
name: 自动构建
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: DockerBuildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 登录dockerhub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: 编译推送
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/oneimg:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/oneimg:${{ github.ref_name }}
|
||||
+1
-1
@@ -18,7 +18,7 @@ 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
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -p 8 -trimpath -ldflags="-s -w" -o /out/oneimg ./main.go
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache ca-certificates tzdata libwebp
|
||||
|
||||
@@ -79,7 +79,7 @@ OneImg 是一个功能完整的自托管图床与图片管理系统。它的核
|
||||
|----------|------|
|
||||
| 本地磁盘 | 默认存储,数据直接落盘 |
|
||||
| Cloudflare R2 | 独立存储类型,支持专用端点与 CDN、浏览器直传 |
|
||||
| S3 兼容 | 支持 Cloudflare R2、阿里云 OSS 等 S3 协议存储 |
|
||||
| S3 兼容 | 支持 SeaweedFS、MinIO、阿里云 OSS 等 S3 协议存储;默认使用 Path Style |
|
||||
| FTP | 传统 FTP 服务器 |
|
||||
| WebDAV | 支持 Nextcloud、坚果云等 WebDAV 服务 |
|
||||
| Telegram | 通过 Telegram Bot 将图片发送到频道/私聊 |
|
||||
@@ -91,6 +91,10 @@ OneImg 是一个功能完整的自托管图床与图片管理系统。它的核
|
||||
- **多存储同步**:本机与远程存储可协同工作,一张图可同时存在于多个存储后端
|
||||
- **故障自动回退**:远程源停用时,已选该源的图片自动回退到本机读取,不中断服务、不删除文件
|
||||
|
||||
S3 存储默认启用 **Path Style** 请求(`Endpoint/Bucket/Object`),适用于 SeaweedFS、MinIO
|
||||
以及通过反向代理暴露的自建 S3 服务。只有在服务明确要求虚拟主机风格(`Bucket.Endpoint/Object`)时,
|
||||
才在存储管理中关闭「强制 Path Style」。旧存储配置未保存该字段时也会按 Path Style 运行。
|
||||
|
||||
### 安全机制
|
||||
|
||||
从登录到存储,多层防护组合使用:
|
||||
@@ -153,6 +157,18 @@ docker compose up -d
|
||||
docker compose exec oneimg cat /app/data/.initial_admin_password
|
||||
```
|
||||
|
||||
### Gitea 容器镜像
|
||||
|
||||
仓库内的 Gitea Actions 工作流在版本标签(`v*`)或手动触发时构建 `linux/amd64` 和
|
||||
`linux/arm64` 镜像,并推送到 Gitea Container Registry。请在仓库设置以下 Secrets:
|
||||
|
||||
- `GITEA_USERNAME`:可推送镜像的 Gitea 用户名
|
||||
- `GITEA_TOKEN`:该用户的包写入令牌
|
||||
|
||||
默认注册表为当前项目使用的 `git.bytevibe.dev`。如果部署在另一台 Gitea,添加仓库变量
|
||||
`GITEA_REGISTRY` 覆盖注册表主机名。镜像名称为 `${GITEA_REGISTRY}/${仓库路径}`,例如
|
||||
`git.bytevibe.dev/gary/oneimg`。
|
||||
|
||||
启动后访问 `http://localhost:8080`
|
||||
|
||||
### Docker 直接运行
|
||||
@@ -278,6 +294,9 @@ docker run -d --name oneimg -p 8080:8080 \
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `APP_URL` | `http://localhost:8080` | **公网必改**。API 返回的图片 URL 取自该值 |
|
||||
| `GITEA_URL` | `https://git.bytevibe.dev` | 账户页管理员版本信息使用的 Gitea 地址 |
|
||||
| `GITEA_REPOSITORY` | `gary/oneimg` | 账户页管理员版本信息使用的仓库路径(`所有者/仓库名`) |
|
||||
| `GITEA_TOKEN` | 空 | 私有仓库访问令牌,公开仓库留空 |
|
||||
| `SERVER_PORT` | `8080` | 监听端口 |
|
||||
| `DB_TYPE` | `sqlite` | `sqlite` / `mysql` / `postgres` |
|
||||
| `DB_TLS_MODE` | `auto` | 数据库 TLS 模式,见上文「数据库」 |
|
||||
|
||||
@@ -15,8 +15,11 @@ import (
|
||||
|
||||
type Config struct {
|
||||
// 服务器配置
|
||||
Port string
|
||||
AppURL string
|
||||
Port string
|
||||
AppURL string
|
||||
GiteaURL string
|
||||
GiteaRepository string
|
||||
GiteaToken string
|
||||
|
||||
// Sqlite3数据库
|
||||
SqlitePath string
|
||||
@@ -91,6 +94,9 @@ func CreateDefaultEnv() {
|
||||
envTemplate := `# 服务器配置
|
||||
SERVER_PORT=8080
|
||||
APP_URL=http://localhost:8080
|
||||
GITEA_URL=https://git.bytevibe.dev
|
||||
GITEA_REPOSITORY=gary/oneimg
|
||||
GITEA_TOKEN=
|
||||
|
||||
# 数据库配置
|
||||
DB_TYPE=sqlite
|
||||
@@ -248,6 +254,9 @@ func NewConfig() {
|
||||
allowedTypes := strings.Split(getEnv("ALLOWED_TYPES", "image/jpeg,image/png,image/gif,image/webp"), ",")
|
||||
port := getEnv("SERVER_PORT", getEnv("PORT", "8080"))
|
||||
appURL := getEnv("APP_URL", "http://localhost:"+port)
|
||||
giteaURL := strings.TrimRight(strings.TrimSpace(getEnv("GITEA_URL", "https://git.bytevibe.dev")), "/")
|
||||
giteaRepository := strings.Trim(strings.TrimSpace(getEnv("GITEA_REPOSITORY", "gary/oneimg")), "/")
|
||||
giteaToken := strings.TrimSpace(getEnv("GITEA_TOKEN", ""))
|
||||
|
||||
// Sqlite3配置
|
||||
sqlitePath := getEnv("SQLITE_PATH", "./data/data.db")
|
||||
@@ -300,6 +309,9 @@ func NewConfig() {
|
||||
App = &Config{
|
||||
Port: port,
|
||||
AppURL: appURL,
|
||||
GiteaURL: giteaURL,
|
||||
GiteaRepository: giteaRepository,
|
||||
GiteaToken: giteaToken,
|
||||
SqlitePath: sqlitePath,
|
||||
DbType: dbType,
|
||||
DbHost: dbHost,
|
||||
|
||||
@@ -319,6 +319,11 @@ func UpdateBuckets(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if type_ == "s3" {
|
||||
if _, supplied := params["s3_force_path_style"]; !supplied {
|
||||
delete(bucketConfig, "s3_force_path_style")
|
||||
}
|
||||
}
|
||||
mergedConfig, err := mergeBucketConfig(bucket.Config, bucketConfig)
|
||||
if err != nil {
|
||||
var requiredSensitiveKeys []string
|
||||
|
||||
@@ -34,7 +34,7 @@ const bucketConnectionTestTimeout = 25 * time.Second
|
||||
var bucketConnectionTestSlots = make(chan struct{}, 4)
|
||||
|
||||
var bucketConfigKeys = map[string][]string{
|
||||
"s3": {"s3_endpoint", "s3_access_key", "s3_secret_key", "s3_bucket"},
|
||||
"s3": {"s3_endpoint", "s3_access_key", "s3_secret_key", "s3_bucket", "s3_force_path_style"},
|
||||
"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"},
|
||||
@@ -73,7 +73,7 @@ func TestBucketConnection(c *gin.Context) {
|
||||
defer cancel()
|
||||
detail, err := testBucketConnection(ctx, bucket)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, result.Error(502, "连接测试失败:"+sanitizeBucketTestError(err, bucket.Config)))
|
||||
c.JSON(http.StatusBadGateway, result.Error(502, "连接测试失败:"+sanitizeStorageError(err, bucket.Config)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ 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" {
|
||||
if key == "r2_cdn_domain" || key == "r2_cdn_mode" || key == "s3_force_path_style" {
|
||||
continue
|
||||
}
|
||||
if key == "ftp_port" {
|
||||
@@ -297,7 +297,9 @@ func testS3CompatibleStorage(ctx context.Context, bucket models.Buckets) (detail
|
||||
}
|
||||
key := ".oneimg-connection-test/" + uuid.NewString() + ".txt"
|
||||
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 {
|
||||
if _, err := client.PutObject(ctx, bucketName, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{
|
||||
ContentType: "text/plain", DisableMultipart: true, DisableContentSha256: true, SendContentMd5: true,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("测试对象写入失败: %s", translateS3Error(err))
|
||||
}
|
||||
defer func() {
|
||||
@@ -419,7 +421,7 @@ func callTelegramTestAPI(ctx context.Context, token, method string, payload any)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeBucketTestError(err error, config map[string]any) string {
|
||||
func sanitizeStorageError(err error, config map[string]any) string {
|
||||
message := err.Error()
|
||||
for key, value := range config {
|
||||
if !secureconfig.IsBucketSensitiveKey(key) {
|
||||
|
||||
@@ -21,6 +21,11 @@ func sliceContains(slice []string, target string) bool {
|
||||
// Keep provider-specific behavior explicit instead of hiding R2 under S3.
|
||||
func validateStorageProvider(storageType string, params map[string]any) error {
|
||||
if storageType == "s3" {
|
||||
if value, supplied := params["s3_force_path_style"]; supplied {
|
||||
if _, valid := value.(bool); !valid {
|
||||
return errors.New("s3_force_path_style 必须是布尔值")
|
||||
}
|
||||
}
|
||||
endpoint, _ := params["s3_endpoint"].(string)
|
||||
if storageS3.IsR2Endpoint(endpoint) {
|
||||
return errors.New("Cloudflare R2 请使用独立的 Cloudflare R2 存储类型")
|
||||
|
||||
@@ -107,7 +107,7 @@ func UploadImages(c *gin.Context) {
|
||||
for _, file := range files {
|
||||
fileResult, err := uploader.Upload(c, &setting, &localBucket, file)
|
||||
if err != nil {
|
||||
uploadResults = append(uploadResults, failedUploadResult(file, "文件保存失败"))
|
||||
uploadResults = append(uploadResults, failedUploadResultWithError(file, "文件保存失败", err, localBucket.Config))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []
|
||||
for _, file := range files {
|
||||
fileResult, uploadErr := uploader.Upload(c, &setting, &bucket, file)
|
||||
if uploadErr != nil {
|
||||
results = append(results, failedUploadResult(file, "文件上传失败"))
|
||||
results = append(results, failedUploadResultWithError(file, "文件上传失败", uploadErr, bucket.Config))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,94 @@
|
||||
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"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"mime/multipart"
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/interfaces"
|
||||
"oneimg/backend/models"
|
||||
"oneimg/backend/services"
|
||||
"oneimg/backend/storage"
|
||||
"oneimg/backend/utils/uploads"
|
||||
)
|
||||
func failedUploadResult(file *multipart.FileHeader,message string)interfaces.ImageUploadResult{
|
||||
return interfaces.ImageUploadResult{Success:false,FileName:file.Filename,Message:message}
|
||||
|
||||
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 failedUploadResultWithError(file *multipart.FileHeader, message string, uploadErr error, config map[string]any) interfaces.ImageUploadResult {
|
||||
if uploadErr != nil {
|
||||
detail := sanitizeStorageError(uploadErr, config)
|
||||
if errors.Is(uploadErr, storage.ErrAccessDenied) {
|
||||
detail = "对象存储拒绝写入,请检查 Access Key、Secret Key 及 Bucket 写权限"
|
||||
} else if errors.Is(uploadErr, storage.ErrNotFound) {
|
||||
detail = "对象存储桶不存在,请检查 Bucket 名称"
|
||||
}
|
||||
if detail != "" {
|
||||
message += ":" + detail
|
||||
}
|
||||
}
|
||||
return failedUploadResult(file, message)
|
||||
}
|
||||
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 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 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 } }
|
||||
func chargeUploadQuota(tx *gorm.DB, bucket models.Buckets, result *interfaces.ImageUploadResult) error {
|
||||
size := result.FileSize + result.ThumbnailSize
|
||||
if size < 0 {
|
||||
return fmt.Errorf("invalid upload size")
|
||||
}
|
||||
col := database.UsageColumn(tx)
|
||||
query := tx.Model(&models.Buckets{}).Where("id = ?", bucket.Id)
|
||||
if bucket.Type != "default" && bucket.Type != "telegram" {
|
||||
// subtraction form cannot overflow and includes equality at the boundary.
|
||||
query = query.Where("capacity = 0 OR ("+col+" <= capacity AND capacity - "+col+" >= ?)", size)
|
||||
}
|
||||
update := query.UpdateColumn("usage", gorm.Expr(col+" + ?", size))
|
||||
if update.Error != nil {
|
||||
return update.Error
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return fmt.Errorf("bucket quota exhausted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func cleanupUnpublishedUpload(c *gin.Context, image models.Image, result *interfaces.ImageUploadResult) {
|
||||
if image.Storage == "default" {
|
||||
cleanupLocalUpload(image)
|
||||
return
|
||||
}
|
||||
// No published Image exists, but materialize a retryable deletion manifest so
|
||||
// an SDK failure cannot orphan already stored objects invisibly.
|
||||
db := database.GetDB().DB
|
||||
image.Id = 0
|
||||
image.Deleting = true
|
||||
image.FolderId = 0
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&image).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
replica := models.ImageStorage{ImageID: image.Id, BucketID: image.BucketId, Storage: image.Storage, Status: models.ImageStorageStatusDeleting, URL: image.Url, Thumbnail: image.Thumbnail, FileSize: result.FileSize, ThumbnailSize: result.ThumbnailSize, Metadata: map[string]any{"delete_charged": false}}
|
||||
return tx.Create(&replica).Error
|
||||
})
|
||||
if err == nil {
|
||||
if services.DeleteImageReplicas(context.Background(), image) == nil {
|
||||
_ = db.Delete(&image).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/utils/result"
|
||||
)
|
||||
|
||||
const maxReleaseResponseBytes = 1 << 20
|
||||
|
||||
var giteaHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// GetLatestVersion proxies the configured Gitea release endpoint so the SPA
|
||||
// does not depend on the Gitea instance allowing cross-origin browser calls.
|
||||
func GetLatestVersion(c *gin.Context) {
|
||||
if config.App == nil || strings.TrimSpace(config.App.GiteaURL) == "" || strings.TrimSpace(config.App.GiteaRepository) == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, result.Error(http.StatusServiceUnavailable, "版本仓库未配置"))
|
||||
return
|
||||
}
|
||||
|
||||
baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(config.App.GiteaURL), "/"))
|
||||
if err != nil || (baseURL.Scheme != "https" && baseURL.Scheme != "http") || baseURL.Host == "" || baseURL.User != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, result.Error(http.StatusServiceUnavailable, "版本仓库地址配置无效"))
|
||||
return
|
||||
}
|
||||
repository := strings.Trim(strings.TrimSpace(config.App.GiteaRepository), "/")
|
||||
if repository == "" || strings.ContainsAny(repository, "?#") {
|
||||
c.JSON(http.StatusServiceUnavailable, result.Error(http.StatusServiceUnavailable, "版本仓库路径配置无效"))
|
||||
return
|
||||
}
|
||||
endpoint := strings.TrimRight(baseURL.String(), "/") + "/api/v1/repos/" + repository + "/releases/latest"
|
||||
|
||||
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, result.Error(http.StatusBadGateway, "版本信息请求失败"))
|
||||
return
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "oneimg-version-check")
|
||||
if token := strings.TrimSpace(config.App.GiteaToken); token != "" {
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
response, err := giteaHTTPClient.Do(request)
|
||||
if err != nil {
|
||||
log.Printf("获取 Gitea 最新版本失败: GET %s: %v", endpoint, err)
|
||||
c.JSON(http.StatusBadGateway, result.Error(http.StatusBadGateway, "版本信息请求失败"))
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
log.Printf("Gitea 最新版本接口返回 HTTP %d: GET %s", response.StatusCode, endpoint)
|
||||
message := fmt.Sprintf("版本仓库返回 HTTP %d", response.StatusCode)
|
||||
switch response.StatusCode {
|
||||
case http.StatusNotFound:
|
||||
message = "Gitea 仓库不存在或尚未发布 Release"
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
message = "Gitea 仓库需要有效的 GITEA_TOKEN"
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, result.Error(http.StatusBadGateway, message))
|
||||
return
|
||||
}
|
||||
|
||||
var release giteaRelease
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, maxReleaseResponseBytes)).Decode(&release); err != nil {
|
||||
log.Printf("解析 Gitea 最新版本响应失败: GET %s: %v", endpoint, err)
|
||||
c.JSON(http.StatusBadGateway, result.Error(http.StatusBadGateway, "版本信息格式无效"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("ok", release))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"oneimg/backend/config"
|
||||
)
|
||||
|
||||
func TestGetLatestVersionProxiesGiteaRelease(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
previousClient := giteaHTTPClient
|
||||
giteaHTTPClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/api/v1/repos/gary/oneimg/releases/latest" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v9.9.9","name":"Test release","published_at":"2026-10-26T00:00:00Z","html_url":"https://git.example/gary/oneimg/releases/tag/v9.9.9","body":"notes"}`)),
|
||||
Header: make(http.Header),
|
||||
Request: r,
|
||||
}, nil
|
||||
})}
|
||||
defer func() { giteaHTTPClient = previousClient }()
|
||||
|
||||
previous := config.App
|
||||
config.App = &config.Config{GiteaURL: "https://git.example", GiteaRepository: "gary/oneimg"}
|
||||
defer func() { config.App = previous }()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/account/version", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = request
|
||||
GetLatestVersion(context)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"tag_name":"v9.9.9"`) {
|
||||
t.Fatalf("release tag missing from response: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func powProviderOrigins() []string {
|
||||
// buildCSP 生成收紧后的 CSP:
|
||||
// - script-src 收敛为 'self' + Turnstile + POW 提供方(原先的 https:
|
||||
// 通配允许 XSS 从任意外部域拉取载荷);
|
||||
// - connect-src 收敛为 'self' + GitHub releases 检查 + Turnstile + POW
|
||||
// - connect-src 收敛为 'self' + Turnstile + POW;版本检查由后端同源代理
|
||||
// 提供方(原先放行任意 http/https);
|
||||
// - frame-src 同步收敛;
|
||||
// - img-src 保持宽松:自定义 CDN 域名(r2_cdn_domain)是管理员可配置
|
||||
@@ -72,7 +72,7 @@ func buildCSP() string {
|
||||
|
||||
pow := strings.Join(uniqueStrings(powProviderOrigins()), " ")
|
||||
scriptSrc := "'self' https://challenges.cloudflare.com " + pow
|
||||
connectSrc := "'self' https://api.github.com https://challenges.cloudflare.com " + pow
|
||||
connectSrc := "'self' https://challenges.cloudflare.com " + pow
|
||||
frameSrc := "'self' https://challenges.cloudflare.com " + pow
|
||||
|
||||
value := "default-src 'self'; script-src " + scriptSrc +
|
||||
|
||||
@@ -14,10 +14,11 @@ type Buckets struct {
|
||||
|
||||
// S3 存储
|
||||
type S3Bucket struct {
|
||||
S3Endpoint string `json:"s3_endpoint"`
|
||||
S3AccessKey string `json:"s3_access_key"`
|
||||
S3SecretKey string `json:"s3_secret_key"`
|
||||
S3Bucket string `json:"s3_bucket"`
|
||||
S3Endpoint string `json:"s3_endpoint"`
|
||||
S3AccessKey string `json:"s3_access_key"`
|
||||
S3SecretKey string `json:"s3_secret_key"`
|
||||
S3Bucket string `json:"s3_bucket"`
|
||||
S3ForcePathStyle *bool `json:"s3_force_path_style"`
|
||||
}
|
||||
|
||||
// R2 兼容S3协议
|
||||
|
||||
@@ -183,6 +183,7 @@ func SetupRoutes(frontendFS fs.FS) *gin.Engine {
|
||||
auth.DELETE("/buckets/:id", middlewares.RequirePermission("storage:delete"), controllers.DeleteBuckets)
|
||||
|
||||
// 账户
|
||||
auth.GET("/account/version", middlewares.AdminOnlyMiddleware(), controllers.GetLatestVersion)
|
||||
auth.DELETE("/account/sessions/:id", controllers.RevokeAccountSession)
|
||||
auth.POST("/account/change", controllers.ChangeAccountInfo)
|
||||
auth.GET("/account/sessions", controllers.ListSessions)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"io"
|
||||
)
|
||||
@@ -30,7 +31,10 @@ func (d *s3Driver) Upload(ctx context.Context, ref Ref, r io.Reader, o UploadOpt
|
||||
result, err := d.client.PutObject(ctx, d.bucket, key, r, o.Size, minio.PutObjectOptions{
|
||||
ContentType: o.ContentType, DisableMultipart: true, DisableContentSha256: true, SendContentMd5: true,
|
||||
})
|
||||
return Info{Size: result.Size, ContentType: o.ContentType}, s3Error(err)
|
||||
if err != nil {
|
||||
return Info{}, fmt.Errorf("S3 PutObject bucket=%q key=%q: %w", d.bucket, key, s3Error(err))
|
||||
}
|
||||
return Info{Size: result.Size, ContentType: o.ContentType}, nil
|
||||
}
|
||||
func (d *s3Driver) Get(ctx context.Context, ref Ref) (io.ReadCloser, error) {
|
||||
key, err := Key(ref.Key)
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
|
||||
// ConvertToS3Bucket 将map转换为S3Bucket
|
||||
func ConvertToS3Bucket(config map[string]any) models.S3Bucket {
|
||||
forcePathStyle := secureconfig.GetBool(config, "s3_force_path_style", true)
|
||||
return models.S3Bucket{
|
||||
S3Endpoint: secureconfig.GetString(config, "s3_endpoint"),
|
||||
S3AccessKey: secureconfig.GetString(config, "s3_access_key"),
|
||||
S3SecretKey: secureconfig.GetString(config, "s3_secret_key"),
|
||||
S3Bucket: secureconfig.GetString(config, "s3_bucket"),
|
||||
S3Endpoint: secureconfig.GetString(config, "s3_endpoint"),
|
||||
S3AccessKey: secureconfig.GetString(config, "s3_access_key"),
|
||||
S3SecretKey: secureconfig.GetString(config, "s3_secret_key"),
|
||||
S3Bucket: secureconfig.GetString(config, "s3_bucket"),
|
||||
S3ForcePathStyle: &forcePathStyle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +62,16 @@ func ConvertToTelegramBucket(config map[string]any) models.TelegramBucket {
|
||||
|
||||
// S3BucketToMap 将S3Bucket转换为map
|
||||
func S3BucketToMap(s3 models.S3Bucket) map[string]any {
|
||||
forcePathStyle := true
|
||||
if s3.S3ForcePathStyle != nil {
|
||||
forcePathStyle = *s3.S3ForcePathStyle
|
||||
}
|
||||
return map[string]any{
|
||||
"s3_endpoint": s3.S3Endpoint,
|
||||
"s3_access_key": s3.S3AccessKey,
|
||||
"s3_secret_key": s3.S3SecretKey,
|
||||
"s3_bucket": s3.S3Bucket,
|
||||
"s3_endpoint": s3.S3Endpoint,
|
||||
"s3_access_key": s3.S3AccessKey,
|
||||
"s3_secret_key": s3.S3SecretKey,
|
||||
"s3_bucket": s3.S3Bucket,
|
||||
"s3_force_path_style": forcePathStyle,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package buckets
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConvertToS3BucketPathStyleDefaults(t *testing.T) {
|
||||
bucket := ConvertToS3Bucket(map[string]any{})
|
||||
if bucket.S3ForcePathStyle == nil || !*bucket.S3ForcePathStyle {
|
||||
t.Fatalf("legacy config path-style default = %v, want true", bucket.S3ForcePathStyle)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
pathpkg "path"
|
||||
"strings"
|
||||
|
||||
"oneimg/backend/database"
|
||||
"oneimg/backend/models"
|
||||
utilsBuckets "oneimg/backend/utils/buckets"
|
||||
)
|
||||
|
||||
func NormalizeDomain(value string) (string, error) {
|
||||
@@ -66,24 +68,62 @@ func Build(setting models.Settings, imagePath string) string {
|
||||
}
|
||||
|
||||
func BuildForStorage(setting models.Settings, storage string, bucketID int, imagePath string) string {
|
||||
bucketName := ""
|
||||
if storage == "s3" && bucketID > 0 {
|
||||
bucketName = lookupBucketName(bucketID)
|
||||
}
|
||||
return BuildForStorageWithBucket(setting, storage, bucketID, bucketName, imagePath)
|
||||
}
|
||||
|
||||
// BuildForStorageWithBucket builds a public URL for an unencrypted S3 object.
|
||||
// The bucket is part of the URL because custom S3 domains commonly expose
|
||||
// buckets below one shared hostname and do not support bucket-specific DNS.
|
||||
func BuildForStorageWithBucket(setting models.Settings, storage string, bucketID int, bucketName, imagePath string) string {
|
||||
path := strings.TrimSpace(imagePath)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if isAbsoluteURL(path) {
|
||||
return path
|
||||
}
|
||||
// Historical active documents must retain OneImg's sandbox response policy.
|
||||
ext := strings.ToLower(pathpkg.Ext(strings.SplitN(path, "?", 2)[0]))
|
||||
if ext == ".svg" || ext == ".svgz" {
|
||||
return path
|
||||
}
|
||||
if !SupportsStorage(storage) || bucketID != setting.DefaultStorage {
|
||||
if !SupportsStorage(storage) {
|
||||
return path
|
||||
}
|
||||
return Build(setting, path)
|
||||
domain, err := NormalizeDomain(setting.PublicImageDomain)
|
||||
if err != nil || domain == "" || strings.TrimSpace(bucketName) == "" {
|
||||
return path
|
||||
}
|
||||
bucketName = strings.Trim(strings.TrimSpace(bucketName), "/")
|
||||
if bucketName == "" || strings.Contains(bucketName, "/") {
|
||||
return path
|
||||
}
|
||||
path = "/" + strings.TrimLeft(path, "/")
|
||||
return strings.TrimRight(domain, "/") + "/" + url.PathEscape(bucketName) + path
|
||||
}
|
||||
|
||||
func lookupBucketName(bucketID int) string {
|
||||
db := database.GetDB()
|
||||
if db == nil || db.DB == nil {
|
||||
return ""
|
||||
}
|
||||
var bucket models.Buckets
|
||||
if err := db.DB.First(&bucket, bucketID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
if bucket.Type == "s3" {
|
||||
return utilsBuckets.ConvertToS3Bucket(bucket.Config).S3Bucket
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func SupportsStorage(storage string) bool {
|
||||
// R2 resolves the selected replica and per-bucket CDN at its stable URL.
|
||||
// A global domain must not bypass private signatures or point at another bucket.
|
||||
// S3-compatible public domains are addressed as /bucket/object. R2 keeps its
|
||||
// own endpoint/CDN and may require signed access, so it is handled separately.
|
||||
return storage == "s3"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package publicurl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"oneimg/backend/models"
|
||||
)
|
||||
|
||||
func TestBuildForStorageWithBucket(t *testing.T) {
|
||||
setting := models.Settings{PublicImageDomain: "https://img.example.com", DefaultStorage: 1}
|
||||
got := BuildForStorageWithBucket(setting, "s3", 7, "piclist-img", "/uploads/2026/10/image.webp")
|
||||
want := "https://img.example.com/piclist-img/uploads/2026/10/image.webp"
|
||||
if got != want {
|
||||
t.Fatalf("BuildForStorageWithBucket() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForStorageWithBucketSupportsNonDefaultS3Bucket(t *testing.T) {
|
||||
setting := models.Settings{PublicImageDomain: "img.example.com", DefaultStorage: 1}
|
||||
got := BuildForStorageWithBucket(setting, "s3", 9, "other-bucket", "uploads/image.webp")
|
||||
want := "https://img.example.com/other-bucket/uploads/image.webp"
|
||||
if got != want {
|
||||
t.Fatalf("BuildForStorageWithBucket() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForStorageWithBucketKeepsOtherStorageAndMissingBucketUnchanged(t *testing.T) {
|
||||
setting := models.Settings{PublicImageDomain: "https://img.example.com"}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
storage string
|
||||
bucket string
|
||||
}{
|
||||
{name: "r2", storage: "r2", bucket: "bucket"},
|
||||
{name: "missing bucket", storage: "s3", bucket: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := "/uploads/image.webp"
|
||||
if got := BuildForStorageWithBucket(setting, test.storage, 2, test.bucket, path); got != path {
|
||||
t.Fatalf("BuildForStorageWithBucket() = %q, want %q", got, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -33,7 +33,9 @@ func NewS3Client(setting models.Settings, buckets models.Buckets) (*minio.Client
|
||||
accessKey = storageConfig.S3AccessKey
|
||||
secretKey = storageConfig.S3SecretKey
|
||||
region = "us-east-1"
|
||||
lookup = minio.BucketLookupDNS
|
||||
// S3-compatible services commonly expose one endpoint for all buckets
|
||||
// and do not provision bucket-specific DNS records.
|
||||
lookup = s3BucketLookup(storageConfig)
|
||||
|
||||
case "r2":
|
||||
storageConfig := utilsBuckets.ConvertToR2Bucket(buckets.Config)
|
||||
@@ -51,20 +53,21 @@ func NewS3Client(setting models.Settings, buckets models.Buckets) (*minio.Client
|
||||
if bucket == "" || endpoint == "" {
|
||||
return nil, fmt.Errorf("S3/R2配置缺失 [bucket:%s, endpoint:%s]", bucket, endpoint)
|
||||
}
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if !strings.Contains(endpoint, "://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
parsedURL, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 Endpoint 失败: %w", err)
|
||||
}
|
||||
|
||||
host = parsedURL.Host
|
||||
if host == "" {
|
||||
path := parsedURL.Path
|
||||
if idx := strings.Index(path, "/"); idx != -1 {
|
||||
host = path[:idx]
|
||||
} else {
|
||||
host = path
|
||||
}
|
||||
if parsedURL.Hostname() == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") ||
|
||||
parsedURL.User != nil || (parsedURL.Path != "" && parsedURL.Path != "/") ||
|
||||
parsedURL.RawQuery != "" || parsedURL.ForceQuery || parsedURL.Fragment != "" {
|
||||
return nil, fmt.Errorf("Endpoint 必须为 HTTP/HTTPS 服务地址,可包含端口,不能包含桶路径、凭据、查询参数或片段")
|
||||
}
|
||||
host = parsedURL.Host
|
||||
|
||||
switch parsedURL.Scheme {
|
||||
case "http":
|
||||
@@ -94,6 +97,13 @@ func NewS3Client(setting models.Settings, buckets models.Buckets) (*minio.Client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func s3BucketLookup(config models.S3Bucket) minio.BucketLookupType {
|
||||
if config.S3ForcePathStyle == nil || *config.S3ForcePathStyle {
|
||||
return minio.BucketLookupPath
|
||||
}
|
||||
return minio.BucketLookupDNS
|
||||
}
|
||||
|
||||
// IsR2Endpoint recognizes Cloudflare's S3 API, including jurisdiction-specific
|
||||
// endpoints. Custom/public domains are deliberately not treated as signing APIs.
|
||||
func IsR2Endpoint(endpoint string) bool {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"oneimg/backend/models"
|
||||
utilsBuckets "oneimg/backend/utils/buckets"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
func TestS3BucketLookupDefaultsToPathStyle(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
config map[string]any
|
||||
want minio.BucketLookupType
|
||||
}{
|
||||
{name: "legacy config", config: map[string]any{}, want: minio.BucketLookupPath},
|
||||
{name: "enabled", config: map[string]any{"s3_force_path_style": true}, want: minio.BucketLookupPath},
|
||||
{name: "disabled", config: map[string]any{"s3_force_path_style": false}, want: minio.BucketLookupDNS},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
bucket := utilsBuckets.ConvertToS3Bucket(test.config)
|
||||
if got := s3BucketLookup(bucket); got != test.want {
|
||||
t.Fatalf("lookup = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewS3ClientNormalizesEndpointWithoutScheme(t *testing.T) {
|
||||
client, err := NewS3Client(models.Settings{}, models.Buckets{Type: "s3", Config: map[string]any{
|
||||
"s3_endpoint": "localhost:8333", "s3_bucket": "oneimg", "s3_access_key": "key", "s3_secret_key": "secret",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewS3Client() error = %v", err)
|
||||
}
|
||||
if got := client.EndpointURL().Scheme; got != "https" {
|
||||
t.Fatalf("endpoint scheme = %q, want https", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewS3ClientRejectsEndpointPath(t *testing.T) {
|
||||
_, err := NewS3Client(models.Settings{}, models.Buckets{Type: "s3", Config: map[string]any{
|
||||
"s3_endpoint": "https://storage.example.com/s3", "s3_bucket": "oneimg", "s3_access_key": "key", "s3_secret_key": "secret",
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("expected endpoint path to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,32 @@ func GetInt(configMap map[string]any, key string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetBool reads a boolean configuration value and applies the supplied default
|
||||
// when the key is absent or cannot be parsed.
|
||||
func GetBool(configMap map[string]any, key string, defaultValue bool) bool {
|
||||
if configMap == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := configMap[key].(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
case float64:
|
||||
return value != 0
|
||||
case float32:
|
||||
return value != 0
|
||||
case int:
|
||||
return value != 0
|
||||
case int64:
|
||||
return value != 0
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func encryptMapValues(configMap map[string]any, sensitiveKeys map[string]struct{}) (map[string]any, error) {
|
||||
result := make(map[string]any, len(configMap))
|
||||
for key, value := range configMap {
|
||||
|
||||
@@ -23,6 +23,10 @@ services:
|
||||
# 站点对外访问地址。API 返回的图片 URL 固定取自该值,不再反射请求 Host,
|
||||
# 公网部署必须改成实际地址,如 https://img.example.com
|
||||
- APP_URL=${APP_URL:-http://localhost:8080}
|
||||
# 账户页管理员版本信息来源(Gitea 仓库)
|
||||
- GITEA_URL=${GITEA_URL:-https://git.bytevibe.dev}
|
||||
- GITEA_REPOSITORY=${GITEA_REPOSITORY:-gary/oneimg}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||
|
||||
# ── 数据库 ───────────────────────────────────────────────
|
||||
# sqlite | mysql | postgres
|
||||
|
||||
@@ -28,7 +28,7 @@ class PopupModal {
|
||||
|
||||
if (this.config.type === 'form' && this.config.formFields.length) {
|
||||
this.config.formFields.forEach(field => {
|
||||
this.state.formData[field.name] = field.defaultValue || '';
|
||||
this.state.formData[field.name] = field.defaultValue ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +153,13 @@ class PopupModal {
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'checkbox':
|
||||
input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.className = 'h-4 w-4 rounded border-light-200 dark:border-dark-100 text-primary focus:ring-primary';
|
||||
input.checked = this.state.formData[field.name] === true || this.state.formData[field.name] === 'true';
|
||||
break;
|
||||
|
||||
default:
|
||||
input = document.createElement('input');
|
||||
@@ -165,13 +172,13 @@ class PopupModal {
|
||||
input.name = field.name;
|
||||
input.id = `${this.titleEl.id}-${field.name}`;
|
||||
label.htmlFor = input.id;
|
||||
input.value = this.state.formData[field.name] || '';
|
||||
if (field.type !== 'checkbox') input.value = this.state.formData[field.name] ?? '';
|
||||
if (field.required) input.required = true;
|
||||
if (field.disabled) input.disabled = true;
|
||||
|
||||
input.addEventListener('change', (e) => {
|
||||
this.state.formData[field.name] = e.target.value;
|
||||
if (field.onChange) field.onChange(this, e.target.value);
|
||||
this.state.formData[field.name] = field.type === 'checkbox' ? e.target.checked : e.target.value;
|
||||
if (field.onChange) field.onChange(this, this.state.formData[field.name]);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
@@ -377,4 +384,4 @@ window.showFormModal = function(options) {
|
||||
return modal;
|
||||
};
|
||||
|
||||
export default PopupModal;
|
||||
export default PopupModal;
|
||||
@@ -109,19 +109,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Github版本卡片:仅管理员可见 -->
|
||||
<!-- Gitea版本卡片:仅管理员可见 -->
|
||||
<div v-if="isAdmin" class="section-card mx-auto overflow-hidden w-full m-4">
|
||||
<div class="panel-content p-6 md:p-8">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-slate-200/70 pb-4 dark:border-white/10">
|
||||
<div>
|
||||
<h2 class="panel-title flex items-center text-xl font-semibold">
|
||||
<span class="panel-icon mr-2 text-2xl">
|
||||
<i class="ri-github-fill"></i>
|
||||
<i class="ri-git-repository-line"></i>
|
||||
</span>
|
||||
版本信息
|
||||
</h2>
|
||||
</div>
|
||||
<span class="inline-flex shrink-0 items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-xs text-slate-500 dark:border-white/10 dark:bg-slate-950 dark:text-slate-400">GitHub Release</span>
|
||||
<span class="inline-flex shrink-0 items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-xs text-slate-500 dark:border-white/10 dark:bg-slate-950 dark:text-slate-400">Gitea Release</span>
|
||||
</div>
|
||||
<!-- 加载中状态 -->
|
||||
<div v-if="isLoadingVersion" class="py-10 text-center">
|
||||
@@ -208,7 +208,7 @@ const formatReleaseDate = (dateStr) => {
|
||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
// 获取Github最新版本信息
|
||||
// 获取 Gitea 最新版本信息
|
||||
const getLatestVersion = async () => {
|
||||
// 非管理员不请求版本信息
|
||||
if (!isAdmin.value) {
|
||||
@@ -217,15 +217,17 @@ const getLatestVersion = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/onexru/oneimg/releases/latest')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
latestVersion.value = data
|
||||
const res = await fetch('/api/account/version', {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
const result = await res.json()
|
||||
if (res.ok && result.code === 200 && result.data) {
|
||||
latestVersion.value = result.data
|
||||
} else {
|
||||
throw new Error('版本接口请求失败')
|
||||
throw new Error(result.message || '版本接口请求失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('版本信息加载失败')
|
||||
message.error(err.message || '版本信息加载失败')
|
||||
console.error('版本请求异常:', err)
|
||||
} finally {
|
||||
isLoadingVersion.value = false
|
||||
@@ -339,4 +341,4 @@ const updateAccount = async () => {
|
||||
isUpdatingAccount.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -147,6 +147,8 @@ const typeSpecificFields = {
|
||||
{ name: 's3_access_key', label: 'AccessKey', type: 'password', placeholder: '请输入 AccessKey', required: true},
|
||||
{ name: 's3_secret_key', label: 'SecretKey', type: 'password', placeholder: '请输入 SecretKey', required: true},
|
||||
{ name: 's3_bucket', label: 'Bucket', type: 'text', placeholder: '请输入 Bucket', required: true},
|
||||
{ name: 's3_force_path_style', label: '强制 Path Style', type: 'checkbox', defaultValue: true, required: false,
|
||||
tip: 'SeaweedFS、MinIO 及多数自建 S3 服务建议开启;请求会使用 Endpoint/Bucket/Object 路径。' },
|
||||
{ name: 'capacity', label: '容量大小', type: 'number', placeholder: '请输入容量大小,单位 GB', required: true}
|
||||
],
|
||||
r2: [
|
||||
|
||||
Reference in new issue
Block a user