fix storage compatibility and gitea release reporting

This commit is contained in:
gary committed 2026-10-26 19:11:23 +08:00
1 parent ed0e2f4ee1
commit a9ee768a6b
29 files changed
+573 -126

No files matched your search

+6
View File
@@ -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
+36
View File
@@ -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 }}
-32
View File
@@ -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
View File
@@ -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
+20 -1
View File
@@ -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 模式,见上文「数据库」 |
+14 -2
View File
@@ -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
+7 -5
View File
@@ -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) {
+5
View File
@@ -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 存储类型")
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+86 -39
View File
@@ -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
}
}
}
+87
View File
@@ -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))
}
+52
View File
@@ -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)
}
+2 -2
View File
@@ -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 +
+5 -4
View File
@@ -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协议
+1
View File
@@ -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)
+5 -1
View File
@@ -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)
+15 -8
View File
@@ -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,
}
}
+10
View File
@@ -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)
}
}
+44 -4
View File
@@ -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"
}
+44
View File
@@ -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
View File
@@ -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 {
+50
View File
@@ -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 {
+4
View File
@@ -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
+12 -5
View File
@@ -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;
+13 -11
View File
@@ -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>
+2
View File
@@ -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: [