Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cb726e3ad | ||
|
|
4fdf3bace7 | ||
|
|
eec7eef96d | ||
|
|
3581f62139 | ||
|
|
b6cfdfeca7 | ||
|
|
2663ab0a38 | ||
|
|
caf53f007b | ||
|
|
79580f4568 | ||
|
|
f752e60b4c | ||
|
|
3cc2d8679c |
No files matched your search
@@ -11,6 +11,24 @@
|
||||
#
|
||||
# 所有项都有内置默认值,全部留空也能启动;下面只列需要按部署环境改的几项。
|
||||
|
||||
# ── 部署入口 ─────────────────────────────────────────────────────
|
||||
# 空值:开发/直连模式,只发布到 127.0.0.1:8080。
|
||||
# 设为 1:加载 Traefik 覆盖配置,不发布宿主机端口。
|
||||
TRAEFIK_ENABLED=
|
||||
COMPOSE_FILE=docker-compose.yml${TRAEFIK_ENABLED:+:docker-compose.traefik.yml}
|
||||
HOST_BIND_IP=127.0.0.1
|
||||
HOST_PORT=8080
|
||||
|
||||
# Traefik 模式设置;TRAEFIK_ENABLED=1 时生效。
|
||||
TRAEFIK_HOST=img.example.com
|
||||
TRAEFIK_NETWORK=frontend
|
||||
TRAEFIK_ENTRYPOINTS=https
|
||||
TRAEFIK_TLS=true
|
||||
TRAEFIK_CERTRESOLVER=cloudflare
|
||||
TRAEFIK_PRIORITY=10
|
||||
# 需在 Traefik file provider 中定义此中间件;没有中间件时请同步移除覆盖文件中的对应 label。
|
||||
TRAEFIK_MIDDLEWARES=real-ip@file
|
||||
|
||||
# ── 必改 ─────────────────────────────────────────────────────────
|
||||
# 站点对外地址:API 返回的图片 URL 固定取自该值(不再反射请求 Host)。
|
||||
# 公网部署必须改成实际地址,否则返回的图片链接不可用。
|
||||
@@ -73,5 +91,6 @@ ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp
|
||||
|
||||
# ── 反向代理 ─────────────────────────────────────────────────────
|
||||
# 逗号分隔 IP/CIDR。仅这些来源的 X-Forwarded-For 会被采信为客户端 IP,
|
||||
# Traefik 模式请填写 Traefik 在共享 Docker 网络中的 IP 或子网;
|
||||
# 留空表示不信任任何代理(直连部署保持留空)。
|
||||
TRUSTED_PROXIES=
|
||||
+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 8 -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 16 -trimpath -ldflags="-s -w" -o /out/oneimg ./main.go
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache ca-certificates tzdata libwebp
|
||||
|
||||
@@ -151,10 +151,14 @@ S3 存储默认启用 **Path Style** 请求(`Endpoint/Bucket/Object`),适
|
||||
```bash
|
||||
git clone https://git.bytevibe.dev/gary/oneimg.git
|
||||
cd oneimg
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
docker compose exec oneimg cat /app/data/.initial_admin_password
|
||||
```
|
||||
|
||||
默认直连模式只在本机发布 `127.0.0.1:8080`,访问 `http://127.0.0.1:8080`。
|
||||
使用 Traefik 时,在 `.env` 设置 `TRAEFIK_ENABLED=1`、`TRAEFIK_HOST` 和公网 `APP_URL`,并确认 Traefik 与 oneImg 共用 `TRAEFIK_NETWORK` 指定的外部网络(默认 `frontend`)。例如网络尚未创建时先执行 `docker network create frontend`,然后运行 `docker compose up -d`。Traefik 模式不会发布宿主机端口;`TRUSTED_PROXIES` 应设为该网络中 Traefik 的 IP 或子网。示例默认挂载 `real-ip@file`,需在 Traefik file provider 中定义;没有该中间件时移除覆盖文件中的对应 label。关闭 Traefik 时清空 `TRAEFIK_ENABLED` 再运行 `docker compose up -d`。
|
||||
|
||||
### Gitea 容器镜像
|
||||
|
||||
仓库内的 Gitea Actions 工作流在推送版本标签(`v*`)或手动触发时构建 `linux/amd64` 和
|
||||
@@ -166,7 +170,7 @@ docker compose exec oneimg cat /app/data/.initial_admin_password
|
||||
默认注册表为 `git.bytevibe.dev`。如需覆盖,设置仓库 Actions Variable `GITEA_REGISTRY`。
|
||||
镜像标签为 `latest` 和触发构建的版本标签,例如 `git.bytevibe.dev/gary/oneimg:latest`。
|
||||
|
||||
启动后访问 `http://localhost:8080`
|
||||
启动后访问 `http://127.0.0.1:8080`
|
||||
|
||||
### Docker 直接运行
|
||||
|
||||
@@ -287,7 +291,14 @@ docker run -d --name oneimg -p 8080:8080 \
|
||||
| `GITEA_URL` | `https://git.bytevibe.dev` | 账户页管理员版本信息使用的 Gitea 地址 |
|
||||
| `GITEA_REPOSITORY` | `gary/oneimg` | 账户页管理员版本信息使用的仓库路径(`所有者/仓库名`) |
|
||||
| `GITEA_TOKEN` | 空 | 私有仓库访问令牌,公开仓库留空 |
|
||||
| `SERVER_PORT` | `8080` | 监听端口 |
|
||||
| `SERVER_PORT` | `8080` | 容器内监听端口 |
|
||||
| `HOST_BIND_IP` / `HOST_PORT` | `127.0.0.1` / `8080` | 直连模式发布到宿主机的地址与端口 |
|
||||
| `TRAEFIK_ENABLED` | 空 | 设为 `1` 加载 Traefik 覆盖配置;空值使用直连模式 |
|
||||
| `TRAEFIK_HOST` | `img.example.com` | Traefik Host 路由域名 |
|
||||
| `TRAEFIK_NETWORK` | `frontend` | Traefik 与 oneImg 共用的外部 Docker 网络 |
|
||||
| `TRAEFIK_ENTRYPOINTS` | `https` | Traefik 入口点 |
|
||||
| `TRAEFIK_CERTRESOLVER` | `cloudflare` | Traefik TLS 证书解析器 |
|
||||
| `TRAEFIK_MIDDLEWARES` | `real-ip@file` | Traefik 中间件列表,需在 Traefik 中预先定义 |
|
||||
| `DB_TYPE` | `sqlite` | `sqlite` / `mysql` / `postgres` |
|
||||
| `DB_TLS_MODE` | `auto` | 数据库 TLS 模式,见上文「数据库」 |
|
||||
| `DEFAULT_USER` / `DEFAULT_PASS` | `admin` / 空 | 初始管理员;`DEFAULT_PASS` 留空则随机生成 |
|
||||
|
||||
@@ -10,5 +10,9 @@ const (
|
||||
HardRandomImageLimit = 100
|
||||
DefaultPowVerifyTimeoutSeconds = 5
|
||||
HardPowVerifyTimeoutSeconds = 15
|
||||
MaxImageDimension = 10_000
|
||||
MaxImagePixels = 24_000_000
|
||||
HardMaxImageDimension = 12_000
|
||||
HardMaxImagePixels = 32_000_000
|
||||
LegacyPowVerifyURL = "https://cha.eta.im/api/validate"
|
||||
)
|
||||
@@ -1,9 +1,13 @@
|
||||
package controllers
|
||||
|
||||
import "oneimg/backend/utils/images"
|
||||
import (
|
||||
"oneimg/backend/models"
|
||||
"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
|
||||
type downloadedImageInfo struct{ MIME string }
|
||||
|
||||
func inspectDownloadedImage(data []byte, setting ...models.Settings) (downloadedImageInfo, error) {
|
||||
_, actual, err := images.ValidateDirectImage(data, "", setting...)
|
||||
return downloadedImageInfo{MIME: actual}, err
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -27,6 +28,18 @@ import (
|
||||
// safefetch's DNS, redirect, and private-address defenses.
|
||||
var fetchUploadURL = safefetch.Get
|
||||
|
||||
// fetchUploadURLWithReferer is injectable for tests that exercise hotlink
|
||||
// protected sources while keeping the regular fetch path unchanged.
|
||||
var fetchUploadURLWithReferer = safefetch.GetWithReferer
|
||||
|
||||
func fetchURLForUpload(ctx context.Context, rawURL string, timeout time.Duration, referer string) (*http.Response, error) {
|
||||
referer = strings.TrimSpace(referer)
|
||||
if referer == "" {
|
||||
return fetchUploadURL(ctx, rawURL, timeout)
|
||||
}
|
||||
return fetchUploadURLWithReferer(ctx, rawURL, timeout, referer)
|
||||
}
|
||||
|
||||
// 通过URL上传图片
|
||||
func UploadImagesByURL(c *gin.Context) {
|
||||
release, ok := acquireUploadRequest(c)
|
||||
@@ -49,6 +62,7 @@ func UploadImagesByURL(c *gin.Context) {
|
||||
|
||||
type URLUploadRequest struct {
|
||||
Urls string `json:"url" binding:"required"`
|
||||
Referer string `json:"referer"`
|
||||
Tag string `json:"tag_id"`
|
||||
FolderID int `json:"folder_id"`
|
||||
BucketID string `json:"bucket_id"` // 兼容旧客户端,目标存储源以用户配置为准。
|
||||
@@ -81,7 +95,7 @@ func UploadImagesByURL(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !setting.MultiStorageSync {
|
||||
uploadImageByURLLegacy(c, setting, req.Urls, req.Tag, req.BucketID, req.FolderID)
|
||||
uploadImageByURLLegacy(c, setting, req.Urls, req.Referer, req.Tag, req.BucketID, req.FolderID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,7 +110,7 @@ func UploadImagesByURL(c *gin.Context) {
|
||||
uc.Fail(400, "URL 不合法或禁止访问内网地址")
|
||||
return
|
||||
}
|
||||
resp, err := fetchUploadURL(c.Request.Context(), req.Urls, 60*time.Second)
|
||||
resp, err := fetchURLForUpload(c.Request.Context(), req.Urls, 60*time.Second, req.Referer)
|
||||
if err != nil {
|
||||
uc.Fail(400, "图片下载失败:%v", err)
|
||||
return
|
||||
@@ -125,7 +139,7 @@ func UploadImagesByURL(c *gin.Context) {
|
||||
uc.Fail(http.StatusBadRequest, "URL 图片超过限制或下载失败")
|
||||
return
|
||||
}
|
||||
info, err := inspectDownloadedImage(fileBytes)
|
||||
info, err := inspectDownloadedImage(fileBytes, setting)
|
||||
if err != nil || !images.AllowedMIME(info.MIME, strings.Split(setting.AllowedTypes, ",")) {
|
||||
uc.Fail(http.StatusBadRequest, "URL不是允许的安全图片")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFetchURLForUploadPassesReferer(t *testing.T) {
|
||||
original := fetchUploadURLWithReferer
|
||||
defer func() { fetchUploadURLWithReferer = original }()
|
||||
|
||||
var gotReferer string
|
||||
fetchUploadURLWithReferer = func(_ context.Context, rawURL string, timeout time.Duration, referer string) (*http.Response, error) {
|
||||
if rawURL != "https://example.com/image.webp" {
|
||||
t.Fatalf("unexpected URL: %s", rawURL)
|
||||
}
|
||||
if timeout != 5*time.Second {
|
||||
t.Fatalf("unexpected timeout: %s", timeout)
|
||||
}
|
||||
gotReferer = referer
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("image"))}, nil
|
||||
}
|
||||
|
||||
resp, err := fetchURLForUpload(context.Background(), "https://example.com/image.webp", 5*time.Second, " https://example.com/ ")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchURLForUpload returned error: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if gotReferer != "https://example.com/" {
|
||||
t.Fatalf("Referer = %q, want trimmed value", gotReferer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchURLForUploadKeepsDefaultFetcherWithoutReferer(t *testing.T) {
|
||||
original := fetchUploadURL
|
||||
defer func() { fetchUploadURL = original }()
|
||||
|
||||
called := false
|
||||
fetchUploadURL = func(_ context.Context, rawURL string, timeout time.Duration) (*http.Response, error) {
|
||||
called = true
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("image"))}, nil
|
||||
}
|
||||
|
||||
resp, err := fetchURLForUpload(context.Background(), "https://example.com/image.webp", time.Second, " ")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchURLForUpload returned error: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if !called {
|
||||
t.Fatal("default fetcher was not called")
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func uploadImagesLegacy(c *gin.Context, setting models.Settings, existingTags []
|
||||
respondUploadBatch(uc, results, "上传成功", 0)
|
||||
}
|
||||
|
||||
func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag, rawBucketID string, folderID int) {
|
||||
func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, referer, tag, rawBucketID string, folderID int) {
|
||||
uc := uploads.NewUploadContext(c)
|
||||
db := database.GetDB()
|
||||
bucketID, err := resolveLegacyRequestedBucketID(c, setting, rawBucketID)
|
||||
@@ -196,7 +196,7 @@ func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag
|
||||
uc.Fail(http.StatusBadRequest, "URL 不合法或禁止访问内网地址")
|
||||
return
|
||||
}
|
||||
resp, err := fetchUploadURL(c.Request.Context(), rawURL, 60*time.Second)
|
||||
resp, err := fetchURLForUpload(c.Request.Context(), rawURL, 60*time.Second, referer)
|
||||
if err != nil {
|
||||
uc.Fail(http.StatusBadRequest, "图片下载失败:%v", err)
|
||||
return
|
||||
@@ -224,7 +224,7 @@ func uploadImageByURLLegacy(c *gin.Context, setting models.Settings, rawURL, tag
|
||||
uc.Fail(http.StatusBadRequest, "URL 图片超过限制或下载失败")
|
||||
return
|
||||
}
|
||||
info, err := inspectDownloadedImage(fileBytes)
|
||||
info, err := inspectDownloadedImage(fileBytes, setting)
|
||||
if err != nil || !images.AllowedMIME(info.MIME, strings.Split(setting.AllowedTypes, ",")) {
|
||||
uc.Fail(http.StatusBadRequest, "URL不是允许的安全图片")
|
||||
return
|
||||
|
||||
@@ -26,6 +26,8 @@ func configurableFieldMetadata() 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: "随机图片接口每次最多返回数量"},
|
||||
"max_image_pixels": {Type: "integer", Default: config.MaxImagePixels, Min: 1_000_000, Max: config.HardMaxImagePixels, Permission: "setting:admin", Description: "图片允许的最大总像素数(仅管理员)"},
|
||||
"max_image_dimension": {Type: "integer", Default: config.MaxImageDimension, Min: 1024, Max: config.HardMaxImageDimension, Permission: "setting:admin", 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)验证地址;空值沿用旧提供方,不允许凭据、查询参数、片段或重定向"},
|
||||
@@ -33,6 +35,11 @@ func configurableFieldMetadata() map[string]settingFieldMetadata {
|
||||
"pow_local_fallback": {Type: "boolean", Default: false, Permission: "setting:security", Description: "显式改用本地 cap-pow,不联系旧 POW 提供方;默认关闭以兼容旧站点"},
|
||||
}
|
||||
}
|
||||
|
||||
func isImageDimensionSetting(key string) bool {
|
||||
return key == "max_image_pixels" || key == "max_image_dimension"
|
||||
}
|
||||
|
||||
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)}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ var SettingKeyPermissionMap = map[string]string{
|
||||
"multi_storage_sync": "setting:upload",
|
||||
"encrypted_storage": "setting:upload",
|
||||
"save_original_name": "setting:upload",
|
||||
"max_image_pixels": "setting:admin",
|
||||
"max_image_dimension": "setting:admin",
|
||||
|
||||
// --- 图片处理 ---
|
||||
"watermark_enable": "setting:image",
|
||||
|
||||
@@ -41,6 +41,10 @@ func GetSettings(c *gin.Context) {
|
||||
}
|
||||
response := secureconfig.SanitizeSettingsForResponse(settingModel)
|
||||
addConfigurableSettings(response, settingModel)
|
||||
if isAdmin {
|
||||
response["max_image_pixels"] = settingModel.MaxImagePixels
|
||||
response["max_image_dimension"] = settingModel.MaxImageDimension
|
||||
}
|
||||
if url, err := oidcCallbackURL(settingModel); err == nil {
|
||||
response["oidc_redirect_url_effective"] = url
|
||||
}
|
||||
@@ -65,7 +69,7 @@ func GetSettings(c *gin.Context) {
|
||||
filtered := filterSettings(response, req.Keys)
|
||||
permissions := []string{}
|
||||
if isAdmin {
|
||||
permissions = []string{"setting:upload", "setting:image", "setting:security", "setting:notification", "setting:api", "setting:seo"}
|
||||
permissions = []string{"setting:upload", "setting:image", "setting:security", "setting:notification", "setting:api", "setting:seo", "setting:admin"}
|
||||
} else {
|
||||
filterDelegatedSettings(filtered, user.Permission)
|
||||
for _, code := range user.Permission.Codes {
|
||||
|
||||
@@ -47,7 +47,11 @@ func UpdateSettings(c *gin.Context) {
|
||||
c.JSON(401, result.Error(401, "未登录"))
|
||||
return
|
||||
}
|
||||
if user.ID != models.SuperAdminID {
|
||||
if isImageDimensionSetting(req.Key) && user.Role != models.RoleAdmin {
|
||||
c.JSON(http.StatusForbidden, result.Error(403, "仅管理员可以修改图片尺寸限制"))
|
||||
return
|
||||
}
|
||||
if user.ID != models.SuperAdminID && !isImageDimensionSetting(req.Key) {
|
||||
requiredPerm := getSettingRequiredPerm(req.Key)
|
||||
if requiredPerm == "" {
|
||||
c.JSON(400, result.Error(400, "未知的设置项"))
|
||||
|
||||
@@ -55,7 +55,7 @@ func buildSettingsUpdate(key string, value any, fieldName string, fieldType refl
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "max_upload_files", "tag_max_length", "random_image_limit", "pow_verify_timeout_seconds":
|
||||
case "max_upload_files", "tag_max_length", "random_image_limit", "pow_verify_timeout_seconds", "max_image_pixels", "max_image_dimension":
|
||||
n, err := strictSettingInt(value)
|
||||
return key, n, err
|
||||
case "pow_verify_url", "pow_script_url", "pow_widget_url":
|
||||
|
||||
@@ -25,6 +25,7 @@ type giteaRelease struct {
|
||||
PublishedAt string `json:"published_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
|
||||
// GetLatestVersion proxies the configured Gitea release endpoint so the SPA
|
||||
@@ -64,12 +65,36 @@ func GetLatestVersion(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
// Some Gitea versions do not expose prereleases through /releases/latest.
|
||||
// Fall back to the release list so repositories that publish prereleases
|
||||
// still show their most recently published release in the account page.
|
||||
listRequest, requestErr := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, endpointWithoutLatest(endpoint)+"?limit=50", nil)
|
||||
if requestErr == nil {
|
||||
listRequest.Header = request.Header.Clone()
|
||||
listResponse, listErr := giteaHTTPClient.Do(listRequest)
|
||||
if listErr == nil {
|
||||
defer listResponse.Body.Close()
|
||||
if listResponse.StatusCode == http.StatusOK {
|
||||
var releases []giteaRelease
|
||||
if decodeErr := json.NewDecoder(io.LimitReader(listResponse.Body, maxReleaseResponseBytes)).Decode(&releases); decodeErr == nil {
|
||||
for _, release := range releases {
|
||||
if !release.Draft && release.TagName != "" {
|
||||
c.JSON(http.StatusOK, result.Success("ok", release))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
message = "Gitea 返回 404:请检查 GITEA_URL、GITEA_REPOSITORY、仓库访问令牌,或确认仓库存在可见的 Release"
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
message = "Gitea 仓库需要有效的 GITEA_TOKEN"
|
||||
}
|
||||
@@ -85,3 +110,7 @@ func GetLatestVersion(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, result.Success("ok", release))
|
||||
}
|
||||
|
||||
func endpointWithoutLatest(endpoint string) string {
|
||||
return strings.TrimSuffix(endpoint, "/latest")
|
||||
}
|
||||
@@ -45,6 +45,51 @@ func TestGetLatestVersionProxiesGiteaRelease(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestVersionFallsBackToReleaseList(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
previousClient := giteaHTTPClient
|
||||
giteaHTTPClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
response := func(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
Request: r,
|
||||
}
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/gary/oneimg/releases/latest":
|
||||
return response(http.StatusNotFound, `{"message":"release not found"}`), nil
|
||||
case "/api/v1/repos/gary/oneimg/releases":
|
||||
if r.URL.Query().Get("limit") != "50" {
|
||||
t.Fatalf("unexpected release list query: %s", r.URL.RawQuery)
|
||||
}
|
||||
return response(http.StatusOK, `[{"tag_name":"v10.0.0-rc1","name":"Release candidate","draft":false,"published_at":"2026-10-26T00:00:00Z"}]`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
return nil, 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":"v10.0.0-rc1"`) {
|
||||
t.Fatalf("fallback release 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) {
|
||||
|
||||
@@ -5,16 +5,18 @@ 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
|
||||
CompressImage bool
|
||||
SaveWebp bool
|
||||
Thumbnail bool
|
||||
EncryptedStorage bool
|
||||
WatermarkEnable bool
|
||||
WatermarkText string
|
||||
WatermarkPos string
|
||||
WatermarkSize int
|
||||
WatermarkColor string
|
||||
WatermarkOpac float64
|
||||
MaxImagePixels int
|
||||
MaxImageDimension int
|
||||
}
|
||||
|
||||
// DirectUploadTask is both the durable queue and the quota reservation ledger.
|
||||
|
||||
@@ -36,7 +36,7 @@ type Settings struct {
|
||||
StartRegister bool `gorm:"column:start_register;default:false" json:"start_register"` // 是否启用注册(默认关闭)
|
||||
|
||||
// 默认存储;游客 0 沿用系统默认,显式配置不改变游客登录开关。
|
||||
GuestStorage int `gorm:"column:guest_storage;default:0" json:"guest_storage"`
|
||||
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"` // 是否加密新写入到各存储源的图片文件
|
||||
@@ -60,13 +60,15 @@ type Settings struct {
|
||||
CASSuperAdminUsername string `gorm:"column:cas_super_admin_username;default:''" json:"cas_super_admin_username"`
|
||||
|
||||
// 默认上传配置
|
||||
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} 秒
|
||||
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"` // 文件最大上传大小
|
||||
MaxImagePixels int `gorm:"column:max_image_pixels;default:24000000" json:"max_image_pixels"`
|
||||
MaxImageDimension int `gorm:"column:max_image_dimension;default:10000" json:"max_image_dimension"`
|
||||
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"` // 图片直链域名(用于非本地存储直接访问)
|
||||
|
||||
@@ -112,11 +112,11 @@ func directResponse(db *gorm.DB, t models.DirectUploadTask) (DirectUploadRespons
|
||||
}
|
||||
|
||||
func directSnapshot(s models.Settings) models.DirectUploadSettings {
|
||||
return models.DirectUploadSettings{CompressImage: s.CompressImage, SaveWebp: s.SaveWebp, Thumbnail: s.Thumbnail, EncryptedStorage: s.EncryptedStorage, WatermarkEnable: s.WatermarkEnable, WatermarkText: s.WatermarkText, WatermarkPos: s.WatermarkPos, WatermarkSize: s.WatermarkSize, WatermarkColor: s.WatermarkColor, WatermarkOpac: s.WatermarkOpac}
|
||||
return models.DirectUploadSettings{CompressImage: s.CompressImage, SaveWebp: s.SaveWebp, Thumbnail: s.Thumbnail, EncryptedStorage: s.EncryptedStorage, WatermarkEnable: s.WatermarkEnable, WatermarkText: s.WatermarkText, WatermarkPos: s.WatermarkPos, WatermarkSize: s.WatermarkSize, WatermarkColor: s.WatermarkColor, WatermarkOpac: s.WatermarkOpac, MaxImagePixels: s.MaxImagePixels, MaxImageDimension: s.MaxImageDimension}
|
||||
}
|
||||
|
||||
func directProcessingSettings(s models.DirectUploadSettings) models.Settings {
|
||||
return models.Settings{CompressImage: s.CompressImage, SaveWebp: s.SaveWebp, Thumbnail: false, EncryptedStorage: s.EncryptedStorage, WatermarkEnable: s.WatermarkEnable, WatermarkText: s.WatermarkText, WatermarkPos: s.WatermarkPos, WatermarkSize: s.WatermarkSize, WatermarkColor: s.WatermarkColor, WatermarkOpac: s.WatermarkOpac}
|
||||
return models.Settings{CompressImage: s.CompressImage, SaveWebp: s.SaveWebp, Thumbnail: false, EncryptedStorage: s.EncryptedStorage, WatermarkEnable: s.WatermarkEnable, WatermarkText: s.WatermarkText, WatermarkPos: s.WatermarkPos, WatermarkSize: s.WatermarkSize, WatermarkColor: s.WatermarkColor, WatermarkOpac: s.WatermarkOpac, MaxImagePixels: s.MaxImagePixels, MaxImageDimension: s.MaxImageDimension}
|
||||
}
|
||||
|
||||
func directValidRequest(r CreateDirectUploadRequest, s models.Settings) error {
|
||||
|
||||
@@ -49,7 +49,7 @@ func directProcessMain(ctx context.Context, db *gorm.DB, t models.DirectUploadTa
|
||||
if err = directVerifyBytes(data, t); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err = images.ValidateDirectImage(data, t.ContentType); err != nil {
|
||||
if _, _, err = images.ValidateDirectImage(data, t.ContentType, directProcessingSettings(t.Settings)); err != nil {
|
||||
return directImageError(err)
|
||||
}
|
||||
processed, err := images.ProcessDirectImage(data, t.ContentType, directProcessingSettings(t.Settings))
|
||||
@@ -177,7 +177,7 @@ func directProcessThumbnail(ctx context.Context, db *gorm.DB, t models.DirectUpl
|
||||
if err != nil {
|
||||
return directError(503, "encryption_unavailable", "主图解密失败,请检查加密配置", true)
|
||||
}
|
||||
thumb, err := images.GenerateDirectThumbnail(plain)
|
||||
thumb, err := images.GenerateDirectThumbnail(plain, directProcessingSettings(t.Settings))
|
||||
if err != nil {
|
||||
return directImageError(err)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func directRunSafe(fn func() error) (err error) {
|
||||
|
||||
func directImageError(err error) error {
|
||||
if errors.Is(err, images.ErrImageDimensions) {
|
||||
return directError(422, "image_dimensions", "图片尺寸过大(最多 1600 万像素、单边 8192;动图最多 200 帧和 6400 万帧像素)", false)
|
||||
return directError(422, "image_dimensions", "图片尺寸超过管理员设置的像素或单边尺寸限制;动图最多 200 帧和 6400 万帧像素", false)
|
||||
}
|
||||
if errors.Is(err, images.ErrUnsupportedFormat) {
|
||||
return directError(415, "invalid_image", "实际文件格式不受支持或与声明不一致", false)
|
||||
|
||||
@@ -100,6 +100,9 @@ func (d *telegramDriver) Upload(ctx context.Context, ref Ref, r io.Reader, o Upl
|
||||
if err = writer.WriteField("chat_id", d.chat); err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
if err = writer.WriteField("disable_content_type_detection", "true"); err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
part, err := writer.CreateFormFile(field, filename)
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
|
||||
@@ -10,20 +10,34 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/models"
|
||||
)
|
||||
|
||||
const DirectMaxPixels = 16_000_000
|
||||
const DirectMaxDimension = 8192
|
||||
const DirectMaxPixels = config.MaxImagePixels
|
||||
const DirectMaxDimension = config.MaxImageDimension
|
||||
|
||||
var ErrImageDimensions = errors.New("image dimensions exceed limit")
|
||||
|
||||
// ValidateDirectImage checks actual format and allocation bounds before decoding.
|
||||
// SVG is deliberately unsupported: never trust a browser MIME for active content.
|
||||
func ValidateDirectImage(data []byte, expected string) (image.Config, string, error) {
|
||||
func ImageDimensionLimits(setting models.Settings) (int, int) {
|
||||
pixels, dimension := setting.MaxImagePixels, setting.MaxImageDimension
|
||||
if pixels <= 0 || pixels > config.HardMaxImagePixels {
|
||||
pixels = config.MaxImagePixels
|
||||
}
|
||||
if dimension <= 0 || dimension > config.HardMaxImageDimension {
|
||||
dimension = config.MaxImageDimension
|
||||
}
|
||||
return pixels, dimension
|
||||
}
|
||||
|
||||
func ValidateDirectImage(data []byte, expected string, settings ...models.Settings) (image.Config, string, error) {
|
||||
expected = declaredImageMIME(expected)
|
||||
var cfg image.Config
|
||||
if int64(len(data)) > MaxUploadBytes { return cfg, "", ErrFileTooLarge }
|
||||
if int64(len(data)) > MaxUploadBytes {
|
||||
return cfg, "", ErrFileTooLarge
|
||||
}
|
||||
var format string
|
||||
var err error
|
||||
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||
@@ -36,7 +50,11 @@ func ValidateDirectImage(data []byte, expected string) (image.Config, string, er
|
||||
if err != nil || mime == "" || (expected != "" && expected != mime) {
|
||||
return cfg, "", ErrUnsupportedFormat
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > DirectMaxDimension || cfg.Height > DirectMaxDimension || int64(cfg.Width)*int64(cfg.Height) > DirectMaxPixels {
|
||||
pixels, dimension := DirectMaxPixels, DirectMaxDimension
|
||||
if len(settings) > 0 {
|
||||
pixels, dimension = ImageDimensionLimits(settings[0])
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > dimension || cfg.Height > dimension || int64(cfg.Width)*int64(cfg.Height) > int64(pixels) {
|
||||
return cfg, "", ErrImageDimensions
|
||||
}
|
||||
if format == "gif" {
|
||||
@@ -122,8 +140,8 @@ func validateDirectGIF(data []byte, cfg image.Config) error {
|
||||
return ErrUnsupportedFormat
|
||||
}
|
||||
|
||||
func decodeDirectImage(data []byte, mime string) (image.Image, string, error) {
|
||||
if _, actual, err := ValidateDirectImage(data, mime); err != nil {
|
||||
func decodeDirectImage(data []byte, mime string, settings ...models.Settings) (image.Image, string, error) {
|
||||
if _, actual, err := ValidateDirectImage(data, mime, settings...); err != nil {
|
||||
return nil, "", err
|
||||
} else {
|
||||
mime = actual
|
||||
@@ -147,8 +165,9 @@ func decodeDirectImage(data []byte, mime string) (image.Image, string, error) {
|
||||
// ProcessDirectImage reuses the main-image pipeline, not the synchronous thumbnail
|
||||
// pipeline. It also corrects the legacy compress-without-SaveWebp MIME mismatch.
|
||||
func ProcessDirectImage(data []byte, mime string, setting models.Settings) (*ProcessedImage, error) {
|
||||
release := AcquireProcessing(); defer release()
|
||||
im, format, err := decodeDirectImage(data, mime)
|
||||
release := AcquireProcessing()
|
||||
defer release()
|
||||
im, format, err := decodeDirectImage(data, mime, setting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,7 +177,7 @@ func ProcessDirectImage(data []byte, mime string, setting models.Settings) (*Pro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, actual, err := ValidateDirectImage(out, "")
|
||||
cfg, actual, err := ValidateDirectImage(out, "", setting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,9 +187,10 @@ func ProcessDirectImage(data []byte, mime string, setting models.Settings) (*Pro
|
||||
|
||||
// GenerateDirectThumbnail always returns a real WebP thumbnail or an error;
|
||||
// unlike the legacy helper it never silently publishes the original as a thumb.
|
||||
func GenerateDirectThumbnail(data []byte) ([]byte, error) {
|
||||
release := AcquireProcessing(); defer release()
|
||||
im, _, err := decodeDirectImage(data, "")
|
||||
func GenerateDirectThumbnail(data []byte, settings ...models.Settings) ([]byte, error) {
|
||||
release := AcquireProcessing()
|
||||
defer release()
|
||||
im, _, err := decodeDirectImage(data, "", settings...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"oneimg/backend/config"
|
||||
"oneimg/backend/models"
|
||||
)
|
||||
|
||||
func TestValidateDirectImageUsesConfiguredDimensionLimits(t *testing.T) {
|
||||
var data bytes.Buffer
|
||||
if err := png.Encode(&data, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
setting := models.Settings{MaxImagePixels: 3, MaxImageDimension: 2}
|
||||
if _, _, err := ValidateDirectImage(data.Bytes(), "image/png", setting); !errors.Is(err, ErrImageDimensions) {
|
||||
t.Fatalf("expected configured pixel limit to reject image, got %v", err)
|
||||
}
|
||||
|
||||
setting.MaxImagePixels = 4
|
||||
if _, _, err := ValidateDirectImage(data.Bytes(), "image/png", setting); err != nil {
|
||||
t.Fatalf("expected image at configured limits to pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageDimensionLimitsFallsBackToHardBounds(t *testing.T) {
|
||||
pixels, dimension := ImageDimensionLimits(models.Settings{MaxImagePixels: config.HardMaxImagePixels + 1, MaxImageDimension: config.HardMaxImageDimension + 1})
|
||||
if pixels != config.MaxImagePixels || dimension != config.MaxImageDimension {
|
||||
t.Fatalf("invalid settings should fall back to defaults; got pixels=%d dimension=%d", pixels, dimension)
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,9 @@ func (s *ImageService) ProcessImage(
|
||||
defer release()
|
||||
fileBytes, err := ReadDirectLimited(file, UploadByteLimit(int64(setting.MaxFileSize)))
|
||||
if err != nil { return nil, err }
|
||||
img, format, err := decodeDirectImage(fileBytes, declaredImageMIME(header.Header.Get("Content-Type")))
|
||||
img, format, err := decodeDirectImage(fileBytes, declaredImageMIME(header.Header.Get("Content-Type")), setting)
|
||||
if err != nil { return nil, fmt.Errorf("decode image failed: %w", err) }
|
||||
if _, actual, err := ValidateDirectImage(fileBytes, ""); err != nil { return nil, err
|
||||
if _, actual, err := ValidateDirectImage(fileBytes, "", setting); err != nil { return nil, err
|
||||
} else if !AllowedMIME(actual, strings.Split(setting.AllowedTypes, ",")) { return nil, ErrUnsupportedFormat }
|
||||
width, height := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
mimeType := formatMIME(format)
|
||||
@@ -92,8 +92,11 @@ func (s *ImageService) ProcessImage(
|
||||
processedBytes, finalFormat, finalMimeType, err := s.processMainImage(fileBytes, img, format, mimeType, int64(len(fileBytes)), setting)
|
||||
if err != nil { return nil, fmt.Errorf("process main image failed: %w", err) }
|
||||
|
||||
_, finalMimeType, err = ValidateDirectImage(processedBytes, "")
|
||||
if err != nil { return nil, err }; finalFormat = strings.TrimPrefix(finalMimeType, "image/")
|
||||
_, finalMimeType, err = ValidateDirectImage(processedBytes, "", setting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalFormat = strings.TrimPrefix(finalMimeType, "image/")
|
||||
// 5. 处理文件扩展名
|
||||
outputExt := map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
@@ -123,7 +126,9 @@ func (s *ImageService) ProcessImage(
|
||||
stem := strings.TrimSuffix(filepath.Base(strings.ReplaceAll(originalFileName, "\\", "/")), filepath.Ext(originalFileName))
|
||||
if !setting.SaveOriginalName {
|
||||
pattern := setting.FileName
|
||||
if pattern == "" { pattern = "{random}" }
|
||||
if pattern == "" {
|
||||
pattern = "{random}"
|
||||
}
|
||||
stem = s.ReplaceMagicVariables(pattern, originalFileName, userRole)
|
||||
}
|
||||
stem = safeFilenameStem(stem)
|
||||
|
||||
@@ -97,15 +97,20 @@ func isBlockedIP(ip net.IP) bool {
|
||||
|
||||
// Get 使用安全策略下载远端资源。
|
||||
func Get(ctx context.Context, rawURL string, timeout time.Duration) (*http.Response, error) {
|
||||
return get(ctx, rawURL, timeout, true)
|
||||
return get(ctx, rawURL, timeout, true, "")
|
||||
}
|
||||
|
||||
// GetWithReferer downloads a public HTTP resource with an optional Referer header.
|
||||
func GetWithReferer(ctx context.Context, rawURL string, timeout time.Duration, referer string) (*http.Response, error) {
|
||||
return get(ctx, rawURL, timeout, true, strings.TrimSpace(referer))
|
||||
}
|
||||
|
||||
// GetNoRedirect never forwards credential-bearing URLs to a redirected host.
|
||||
func GetNoRedirect(ctx context.Context, rawURL string, timeout time.Duration) (*http.Response, error) {
|
||||
return get(ctx, rawURL, timeout, false)
|
||||
return get(ctx, rawURL, timeout, false, "")
|
||||
}
|
||||
|
||||
func get(ctx context.Context, rawURL string, timeout time.Duration, follow bool) (*http.Response, error) {
|
||||
func get(ctx context.Context, rawURL string, timeout time.Duration, follow bool, referer string) (*http.Response, error) {
|
||||
if err := ValidatePublicHTTPURL(rawURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,6 +179,9 @@ func get(ctx context.Context, rawURL string, timeout time.Duration, follow bool)
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "OneImg-SafeFetch/1.0")
|
||||
if referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
limits "oneimg/backend/config"
|
||||
"oneimg/backend/models"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -20,10 +21,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "golang.org/x/image/webp"
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var frontendFS fs.FS
|
||||
@@ -42,6 +43,8 @@ type WatermarkConfig struct {
|
||||
Opacity float64 // 透明度 (0-1)
|
||||
FontPath string // 字体文件路径
|
||||
EnableDynamicSize bool // 是否启用动态字体大小(默认true)
|
||||
MaxImagePixels int
|
||||
MaxImageDimension int
|
||||
}
|
||||
|
||||
// Init 初始化字体文件系统
|
||||
@@ -111,7 +114,10 @@ func ParseWatermarkParams(c *gin.Context) WatermarkConfig {
|
||||
|
||||
if minSizeStr := c.Query("wm_min_size"); minSizeStr != "" {
|
||||
if minSize, err := strconv.Atoi(minSizeStr); err == nil && minSize > 0 && minSize <= 100 {
|
||||
if minSize > 100 { minSize = 100 }; cfg.MinFontSize = minSize
|
||||
if minSize > 100 {
|
||||
minSize = 100
|
||||
}
|
||||
cfg.MinFontSize = minSize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +144,6 @@ func ParseWatermarkParams(c *gin.Context) WatermarkConfig {
|
||||
cfg.Opacity = opacity
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
return cfg
|
||||
@@ -171,6 +175,8 @@ func WatermarkSetting(setting models.Settings) WatermarkConfig {
|
||||
Opacity: setting.WatermarkOpac,
|
||||
FontPath: "jyhphy.ttf",
|
||||
EnableDynamicSize: dynamicOn,
|
||||
MaxImagePixels: setting.MaxImagePixels,
|
||||
MaxImageDimension: setting.MaxImageDimension,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,16 +412,27 @@ func ProcessImageWithWatermark(reader io.Reader, mimeType string, cfg WatermarkC
|
||||
}
|
||||
|
||||
processingSlots <- struct{}{}
|
||||
defer func(){ <-processingSlots }()
|
||||
defer func() { <-processingSlots }()
|
||||
buf, err := io.ReadAll(io.LimitReader(reader, (32<<20)+1))
|
||||
if err != nil {
|
||||
log.Printf("读取图片数据失败: %v", err)
|
||||
return nil, fmt.Errorf("读取图片数据失败: %v", err)
|
||||
}
|
||||
|
||||
if len(buf) > 32<<20 { return nil, fmt.Errorf("图片超过水印处理限制") }
|
||||
if len(buf) > 32<<20 {
|
||||
return nil, fmt.Errorf("图片超过水印处理限制")
|
||||
}
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(buf))
|
||||
if err != nil || config.Width <= 0 || config.Height <= 0 || config.Width > 8192 || config.Height > 8192 || int64(config.Width)*int64(config.Height) > 16_000_000 { return nil, fmt.Errorf("图片尺寸超过水印处理限制") }
|
||||
maxPixels, maxDimension := cfg.MaxImagePixels, cfg.MaxImageDimension
|
||||
if maxPixels <= 0 || maxPixels > limits.HardMaxImagePixels {
|
||||
maxPixels = limits.MaxImagePixels
|
||||
}
|
||||
if maxDimension <= 0 || maxDimension > limits.HardMaxImageDimension {
|
||||
maxDimension = limits.MaxImageDimension
|
||||
}
|
||||
if err != nil || config.Width <= 0 || config.Height <= 0 || config.Width > maxDimension || config.Height > maxDimension || int64(config.Width)*int64(config.Height) > int64(maxPixels) {
|
||||
return nil, fmt.Errorf("图片尺寸超过水印处理限制")
|
||||
}
|
||||
img, format, err := image.Decode(bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
log.Printf("解码图片失败: %v", err)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
oneimg:
|
||||
ports: !reset []
|
||||
expose:
|
||||
- "${SERVER_PORT:-8080}"
|
||||
networks:
|
||||
- frontend
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.oneimg.rule=Host(`${TRAEFIK_HOST:-img.example.com}`)"
|
||||
- "traefik.http.routers.oneimg.entrypoints=${TRAEFIK_ENTRYPOINTS:-https}"
|
||||
- "traefik.http.routers.oneimg.tls=${TRAEFIK_TLS:-true}"
|
||||
- "traefik.http.routers.oneimg.tls.certresolver=${TRAEFIK_CERTRESOLVER:-cloudflare}"
|
||||
- "traefik.http.routers.oneimg.priority=${TRAEFIK_PRIORITY:-10}"
|
||||
- "traefik.http.routers.oneimg.middlewares=${TRAEFIK_MIDDLEWARES:-real-ip@file}"
|
||||
- "traefik.http.routers.oneimg.service=oneimg"
|
||||
- "traefik.http.services.oneimg.loadbalancer.server.port=${SERVER_PORT:-8080}"
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:-frontend}
|
||||
+2
-6
@@ -12,10 +12,11 @@
|
||||
services:
|
||||
oneimg:
|
||||
build: .
|
||||
container_name: oneimg
|
||||
# 也可改用官方镜像:image: onexru/oneimg-oneimg
|
||||
# 或加载离线包:docker load -i oneImg_Docker_amd64_v3.5.8.tar(镜像名 oneimg:v3.5.8-amd64)
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}"
|
||||
- "${HOST_BIND_IP:-127.0.0.1}:${HOST_PORT:-8080}:${SERVER_PORT:-8080}"
|
||||
environment:
|
||||
# ── 服务器 ───────────────────────────────────────────────
|
||||
# 监听端口(镜像内 EXPOSE 8080,改动时上面的 ports 会跟着变)
|
||||
@@ -86,8 +87,3 @@ services:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
@@ -62,6 +62,16 @@
|
||||
<input :id="field.key" v-model.number="systemSettings[field.key]" type="number" min="1" max="50" step="1" class="input-modern" @blur="handleFieldBlur(field.key, systemSettings[field.key])" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="isAdmin && systemSettings.max_image_pixels !== undefined" class="setting-group">
|
||||
<label class="field-label" for="max_image_pixels">图片最大总像素数</label>
|
||||
<input id="max_image_pixels" v-model.number="systemSettings.max_image_pixels" type="number" min="1000000" max="32000000" step="100000" class="input-modern" @blur="handleFieldBlur('max_image_pixels', systemSettings.max_image_pixels)" />
|
||||
<div class="field-hint">仅管理员可修改,默认 24000000;硬上限 32000000。</div>
|
||||
</div>
|
||||
<div v-if="isAdmin && systemSettings.max_image_dimension !== undefined" class="setting-group">
|
||||
<label class="field-label" for="max_image_dimension">图片最大单边尺寸</label>
|
||||
<input id="max_image_dimension" v-model.number="systemSettings.max_image_dimension" type="number" min="1024" max="12000" step="100" class="input-modern" @blur="handleFieldBlur('max_image_dimension', systemSettings.max_image_dimension)" />
|
||||
<div class="field-hint">仅管理员可修改,默认 10000 像素;硬上限 12000。</div>
|
||||
</div>
|
||||
<label class="field-label" for="allowed_types">允许上传的图片类型</label>
|
||||
<input id="allowed_types" v-model="systemSettings.allowed_types" type="text" class="input-modern" placeholder="允许上传的图片类型(SVG 不允许上传)" @blur="handleFieldBlur('allowed_types', systemSettings.allowed_types)" />
|
||||
</div>
|
||||
@@ -175,5 +185,5 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
import { settingsContext } from '@/utils/settingsAccess.js';
|
||||
const { systemSettings, activeSettingsTab, activeSettingsTabLabel, presetBuckets, supportsPublicImageDomain, hasPublicImageDomain, publicImageDomainInputDisabled, publicImageDomainHint, handleFieldBlur, handleSelectChange, handleSwitchChange, generatedApiToken, tokenGenerating, generateApiToken, generateRandomGraph } = inject(settingsContext);
|
||||
const { systemSettings, activeSettingsTab, activeSettingsTabLabel, presetBuckets, supportsPublicImageDomain, hasPublicImageDomain, publicImageDomainInputDisabled, publicImageDomainHint, handleFieldBlur, handleSelectChange, handleSwitchChange, generatedApiToken, tokenGenerating, generateApiToken, generateRandomGraph, isAdmin } = inject(settingsContext);
|
||||
</script>
|
||||
@@ -85,6 +85,12 @@ const uploadbyurlmodal = () => {
|
||||
required: true,
|
||||
placeholder: '请输入图片链接'
|
||||
},
|
||||
{
|
||||
name: 'referer',
|
||||
label: '来源页(Referer,可选)',
|
||||
type: 'text',
|
||||
placeholder: '例如:https://bytevibe.dev/'
|
||||
},
|
||||
...(foldersAuthenticated.value ? [{
|
||||
name: 'folder_id', label: '上传文件夹', type: 'select', required: true,
|
||||
defaultValue: String(uploadFolderId), options: folderList,
|
||||
|
||||
@@ -45,6 +45,8 @@ const acceptDirectTask = value => {
|
||||
const previous = directTasks.value.find(task => task.id === value.id);
|
||||
directTasks.value = mergeTasks(directTasks.value, [value]);
|
||||
const task = directTasks.value.find(item => item.id === value.id);
|
||||
const sessionForTask = sessions.get(value.client_id || task?.client_id);
|
||||
sessionForTask?.onItem?.(sessionForTask.file, { status: value.status === 'failed' ? 'failed' : value.status === 'ready' ? 'success' : 'uploading', task: value, image: value.image || undefined, percent: value.percent, loaded: value.loaded, total: value.total, error: value.message || value.clientError }, sessionForTask.clientId);
|
||||
if (task && ['receiving', 'queued', 'processing', 'ready', 'cancelled'].includes(task.status)) {
|
||||
patchTask(taskKey(task), { clientError: '', clientRetryable: false });
|
||||
const session = sessions.get(taskKey(task));
|
||||
@@ -96,9 +98,14 @@ const runSession = async session => {
|
||||
patchTask(key, { busy: true, clientError: '', clientRetryable: false, hasFile: !!session.file });
|
||||
try {
|
||||
await runDirectUpload(session, { api: directApi, signal: controller.signal,
|
||||
onTask: acceptDirectTask, onState: patch => patchTask(key, patch) });
|
||||
onTask: acceptDirectTask, onState: patch => { patchTask(key, patch); session.onItem?.(session.file, { status: 'uploading', ...patch }, session.clientId); } });
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') taskError({ client_id: key }, error);
|
||||
if (error.name !== 'AbortError') {
|
||||
taskError({ client_id: key }, error);
|
||||
session.onItem?.(session.file, { status: 'failed', error: safeMessage(error.message, '上传失败') }, session.clientId);
|
||||
} else {
|
||||
session.onItem?.(session.file, { status: 'failed', error: '已停止传输' }, session.clientId);
|
||||
}
|
||||
} finally {
|
||||
if (taskControllers.get(key) === controller) {
|
||||
taskControllers.delete(key);
|
||||
@@ -107,7 +114,7 @@ const runSession = async session => {
|
||||
if (!isDisposed()) refreshDirectTasks();
|
||||
}
|
||||
};
|
||||
const startDirectFiles = async files => {
|
||||
const startDirectFiles = async (files, onItem = () => {}) => {
|
||||
const activeCount = directTasks.value.filter(task => task.busy || ['awaiting_upload', 'receiving', 'queued', 'processing'].includes(task.status)).length;
|
||||
if (files.length + activeCount > MAX_RECENT_TASKS) {
|
||||
Message.warning(`最多同时保留 ${MAX_RECENT_TASKS} 个未完成任务,请先完成或取消已有任务后再选择文件`);
|
||||
@@ -118,7 +125,7 @@ const startDirectFiles = async files => {
|
||||
try {
|
||||
for (const file of files) {
|
||||
const clientId = newClientId();
|
||||
const session = { clientId, bucketId: selectedBucket.value, folderId, file,
|
||||
const session = { clientId, bucketId: selectedBucket.value, folderId, file, onItem,
|
||||
previewOnReady: files.length === 1 };
|
||||
sessions.set(clientId, session);
|
||||
directTasks.value = mergeTasks(directTasks.value, [{ client_id: clientId, filename: file.name,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fileValidationMessage } from './uploadValidation.js';
|
||||
import { uploadOutcome } from './batchResults.js';
|
||||
import { boundedPage, RECENT_IMAGE_LIMIT } from './renderBounds.js';
|
||||
import { xhrUpload, mergeImages, safeMessage } from './directUpload.js';
|
||||
export function createHomeUploadActions({ API_BASE_URL, isDragOver, isUploading, fileInput, resumeTask, handleResumeFile, clientUploadLimits, uploadConfig, storageConfigLoaded, directEnabled, startDirectFiles, legacyUploading, uploadingCount, uploadProgress, getUploadFolderId = () => 0, multiStorageSync, selectedBucket, recentImages, loadRecentImages, isDisposed, onSingleUpload = () => {} }) {
|
||||
export function createHomeUploadActions({ API_BASE_URL, isDragOver, isUploading, fileInput, resumeTask, handleResumeFile, clientUploadLimits, uploadConfig, storageConfigLoaded, directEnabled, startDirectFiles, legacyUploading, uploadingCount, uploadProgress, getUploadFolderId = () => 0, multiStorageSync, selectedBucket, recentImages, loadRecentImages, isDisposed, onSingleUpload = () => {}, onFilesReady = () => {} }) {
|
||||
let legacyController;
|
||||
const authHeaders = () => ({});
|
||||
const handleDragOver = () => {
|
||||
@@ -28,7 +28,7 @@ const handleDrop = (e) => {
|
||||
const validFiles = validateFiles(files);
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
uploadFiles(validFiles);
|
||||
onFilesReady(validFiles);
|
||||
} else {
|
||||
Message.error('请拖拽有效的 JPG、PNG、GIF 或 WebP 图片;SVG 不允许上传', {
|
||||
duration: 3000,
|
||||
@@ -53,7 +53,7 @@ const handleFileSelect = (e) => {
|
||||
if (files.length > 0) {
|
||||
const validFiles = validateFiles(files);
|
||||
if (validFiles.length > 0) {
|
||||
uploadFiles(validFiles);
|
||||
onFilesReady(validFiles);
|
||||
}
|
||||
}
|
||||
e.target.value = ''; // 清空文件选择
|
||||
@@ -86,7 +86,7 @@ const handlePaste = async (e) => {
|
||||
e.preventDefault();
|
||||
const valid = validateFiles(imageFiles);
|
||||
if (!valid.length) return;
|
||||
uploadFiles(valid);
|
||||
onFilesReady(valid);
|
||||
Message.success(`从剪贴板粘贴了 ${valid.length} 个图片`, {
|
||||
duration: 2000,
|
||||
position: 'top-right'
|
||||
@@ -109,7 +109,7 @@ const validateFiles = (files) => {
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
const uploadFiles = async (files, onItem = () => {}) => {
|
||||
if (isUploading.value) return;
|
||||
if (!storageConfigLoaded.value) {
|
||||
Message.warning('存储配置正在加载,请稍后重试');
|
||||
@@ -118,40 +118,56 @@ const uploadFiles = async (files) => {
|
||||
|
||||
files = validateFiles(files);
|
||||
if (!files.length) return;
|
||||
if (directEnabled()) { await startDirectFiles(files); return; }
|
||||
if (directEnabled()) { await startDirectFiles(files, onItem); return; }
|
||||
legacyUploading.value = true;
|
||||
legacyController = new AbortController();
|
||||
uploadingCount.value = files.length;
|
||||
uploadProgress.value = 0;
|
||||
|
||||
let activeFile = null;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach(file => {
|
||||
const images = [];
|
||||
const errors = [];
|
||||
for (const file of files) {
|
||||
if (isDisposed()) return;
|
||||
activeFile = file;
|
||||
const formData = new FormData();
|
||||
formData.append('images[]', file);
|
||||
});
|
||||
|
||||
// Snapshot before any transfer; never send removed upload-tag state.
|
||||
formData.append('folder_id', String(getUploadFolderId()));
|
||||
if (!multiStorageSync.value) {
|
||||
formData.append('bucket_id', selectedBucket.value || '1');
|
||||
formData.append('folder_id', String(getUploadFolderId()));
|
||||
if (!multiStorageSync.value) formData.append('bucket_id', selectedBucket.value || '1');
|
||||
onItem(file, { status: 'uploading', percent: 0, loaded: 0, total: file.size });
|
||||
try {
|
||||
const { result } = await xhrUpload({ url: `${API_BASE_URL}/api/upload/images`, body: formData, returnEnvelope: true,
|
||||
headers: authHeaders(), signal: legacyController.signal,
|
||||
onProgress: ({ loaded, total, percent }) => {
|
||||
uploadProgress.value = percent;
|
||||
onItem(file, { status: 'uploading', percent, loaded, total: total || file.size });
|
||||
} });
|
||||
const outcome = uploadOutcome(result, 1);
|
||||
images.push(...outcome.images);
|
||||
errors.push(...outcome.errors);
|
||||
if (outcome.failed) onItem(file, { status: 'failed', error: outcome.errors[0]?.message || result.message || '上传失败' });
|
||||
else {
|
||||
onItem(file, { status: 'success', percent: 100, loaded: file.size, total: file.size, image: outcome.images[0] });
|
||||
if (files.length === 1 && outcome.images[0]) onSingleUpload(outcome.images[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
const message = safeMessage(error.message, '上传失败');
|
||||
errors.push({ filename: file.name, message });
|
||||
onItem(file, { status: 'failed', error: message });
|
||||
}
|
||||
}
|
||||
const { result } = await xhrUpload({ url: `${API_BASE_URL}/api/upload/images`, body: formData, returnEnvelope: true,
|
||||
headers: authHeaders(), signal: legacyController.signal,
|
||||
onProgress: ({ percent }) => { uploadProgress.value = percent; } });
|
||||
if (isDisposed()) return;
|
||||
const outcome = uploadOutcome(result, files.length);
|
||||
recentImages.value = boundedPage(mergeImages(recentImages.value, outcome.images), RECENT_IMAGE_LIMIT);
|
||||
if (images.length) recentImages.value = boundedPage(mergeImages(recentImages.value, images), RECENT_IMAGE_LIMIT);
|
||||
await loadRecentImages();
|
||||
if (outcome.failed) {
|
||||
const detail = outcome.errors.map(item => `${item.filename || item.file || '文件'}:${item.message || item.error || '上传失败'}`).join(';');
|
||||
Message.warning(`成功 ${outcome.images.length} 个,失败 ${outcome.failed} 个。成功文件已保留,请勿整批重传。${detail || result.message || ''}`, { duration: 7000, showClose: true });
|
||||
if (errors.length) {
|
||||
const detail = errors.map(item => `${item.filename || item.file || '文件'}:${item.message || item.error || '上传失败'}`).join(';');
|
||||
Message.warning(`成功 ${images.length} 个,失败 ${errors.length} 个。成功文件已保留,请勿整批重传。${detail}`, { duration: 7000, showClose: true });
|
||||
} else {
|
||||
Message.success(multiStorageSync.value ? '已保存到本机,正在后台同步' : `上传成功 ${outcome.images.length} 个`, { duration: 2000, position: 'top-right' });
|
||||
}
|
||||
if (!isDisposed() && files.length === 1 && !outcome.failed && outcome.images.length === 1) {
|
||||
onSingleUpload(outcome.images[0]);
|
||||
Message.success(multiStorageSync.value ? '已保存到本机,正在后台同步' : `上传成功 ${images.length} 个`, { duration: 2000, position: 'top-right' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError' && activeFile) onItem(activeFile, { status: 'failed', error: '已停止传输;服务器可能已接收部分文件,请检查最近上传' });
|
||||
if (!isDisposed()) {
|
||||
if (error.name === 'AbortError') Message.info('已停止上传请求;已接收的文件仍可能完成,请检查最近上传');
|
||||
else Message.error(`上传失败: ${safeMessage(error.message)}`, { duration: 3000, position: 'top-right', showClose: true });
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<!-- 加载失败状态 -->
|
||||
<div v-else class="py-10 text-center text-gray-600 dark:text-gray-400">
|
||||
<i class="ri-error-warning-line text-2xl mb-2"></i>
|
||||
<p>版本信息加载失败,请稍后重试</p>
|
||||
<p>{{ versionError || '版本信息加载失败,请稍后重试' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,6 +200,7 @@ const accountForm = ref({
|
||||
const isUpdatingAccount = ref(false)
|
||||
const isLoadingVersion = ref(true)
|
||||
const latestVersion = ref(null)
|
||||
const versionError = ref('')
|
||||
|
||||
// 格式化发布时间
|
||||
const formatReleaseDate = (dateStr) => {
|
||||
@@ -218,16 +219,32 @@ const getLatestVersion = async () => {
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/account/version', {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
const result = await res.json()
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
throw new Error('版本接口返回了网页内容,请检查服务端版本或反向代理的 /api 路由配置')
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await res.json()
|
||||
} catch {
|
||||
throw new Error('版本接口返回的数据格式无效,请稍后重试')
|
||||
}
|
||||
|
||||
if (res.ok && result.code === 200 && result.data) {
|
||||
latestVersion.value = result.data
|
||||
} else {
|
||||
throw new Error(result.message || '版本接口请求失败')
|
||||
throw new Error(result.message || `版本接口请求失败(HTTP ${res.status})`)
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(err.message || '版本信息加载失败')
|
||||
versionError.value = err.message || '版本信息加载失败,请稍后重试'
|
||||
message.error(versionError.value)
|
||||
console.error('版本请求异常:', err)
|
||||
} finally {
|
||||
isLoadingVersion.value = false
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<div class="upload-icon mb-2.5 text-4xl text-slate-900 dark:text-white sm:text-[42px]">
|
||||
<i class="ri-upload-cloud-line"></i>
|
||||
</div>
|
||||
<h3 class="mb-1.5 text-base font-semibold text-slate-900 dark:text-white">拖拽图片到此处,或点击立即上传</h3>
|
||||
<p class="mx-auto mb-3 max-w-md text-sm leading-5 text-slate-500 dark:text-slate-400">支持常见图片格式、剪贴板和 URL 上传。</p>
|
||||
<h3 class="mb-1.5 text-base font-semibold text-slate-900 dark:text-white">拖拽图片到此处,或点击加入上传队列</h3>
|
||||
<p class="mx-auto mb-3 max-w-md text-sm leading-5 text-slate-500 dark:text-slate-400">先整理要上传的图片,再点击“开始上传”;支持常见图片格式和剪贴板。</p>
|
||||
<p v-if="isGuest" class="mx-auto mb-3 max-w-md text-xs text-secondary">游客图片依赖本浏览器的安全 Cookie。清除 Cookie 或更换浏览器后无法找回,请注册账户保存长期身份。</p>
|
||||
<div class="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<button class="primary-button w-full px-4 py-2 sm:w-auto">
|
||||
@@ -77,6 +77,43 @@
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<section v-if="uploadQueue.length" class="content-panel home-panel-compact mt-3 space-y-3" aria-labelledby="upload-queue-title">
|
||||
<div class="flex flex-col gap-2 border-b border-slate-200/70 pb-2.5 sm:flex-row sm:items-center sm:justify-between dark:border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 id="upload-queue-title" class="text-sm font-semibold text-slate-900 dark:text-white">上传队列</h2>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">{{ uploadQueue.length }} 个文件</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-if="queueHasFinished" type="button" class="soft-button px-3 py-1.5 text-xs" :disabled="queueBusy" @click="clearFinishedQueue">清除已完成</button>
|
||||
<button v-if="!queueBusy" type="button" class="soft-button px-3 py-1.5 text-xs" :disabled="!queueHasRemovable" @click="clearUploadQueue">清空队列</button>
|
||||
<button v-if="queueHasPending" type="button" class="primary-button px-3 py-1.5 text-xs" :disabled="queueBusy || !storageConfigLoaded" @click="startQueuedUpload"><i class="ri-upload-2-line" aria-hidden="true"></i>开始上传</button>
|
||||
<button v-if="queueBusy && !directEnabled()" type="button" class="soft-button px-3 py-1.5 text-xs" @click="cancelLegacyUpload">停止上传</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="divide-y divide-slate-200/70 dark:divide-white/10">
|
||||
<li v-for="item in uploadQueue" :key="item.id" class="flex min-w-0 items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<div class="relative h-12 w-12 shrink-0 overflow-hidden rounded border border-slate-200 bg-slate-100 dark:border-white/10 dark:bg-slate-900">
|
||||
<img v-if="item.previewUrl" :src="item.previewUrl" :alt="`${item.file.name} 预览`" class="h-full w-full object-cover" loading="lazy" decoding="async" />
|
||||
<i v-else class="ri-image-line absolute inset-0 grid place-items-center text-lg text-slate-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<p class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-slate-100" :title="item.file.name">{{ item.file.name }}</p>
|
||||
<span class="shrink-0 text-xs text-slate-500 dark:text-slate-400">{{ formatFileSize(item.loaded ?? item.file.size) }}<template v-if="item.status === 'uploading'"> / {{ formatFileSize(item.total || item.file.size) }}</template></span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs">
|
||||
<span :class="queueStatusClass(item.status)">{{ queueStatusLabel(item.status) }}</span>
|
||||
<span v-if="item.status === 'uploading' && item.percent != null" class="tabular-nums text-slate-600 dark:text-slate-300">{{ Math.round(item.percent) }}%</span>
|
||||
<button v-if="item.status === 'queued' || item.status === 'failed'" type="button" class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded text-slate-500 hover:bg-slate-100 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:hover:bg-white/10" :aria-label="`移除 ${item.file.name}`" title="移除文件" :disabled="queueBusy" @click="removeQueuedFile(item.id)"><i class="ri-close-line" aria-hidden="true"></i></button>
|
||||
<button v-if="item.status === 'success' && item.image?.url" type="button" class="soft-button shrink-0 px-2 py-1 text-xs" @click.stop="showUploadResult(item.image)"><i class="ri-information-line" aria-hidden="true"></i>图片信息</button>
|
||||
</div>
|
||||
<progress v-if="item.status === 'uploading'" class="mt-1.5 block h-1.5 w-full accent-blue-600" :value="item.percent == null ? undefined : item.percent" max="100" :aria-label="`${item.file.name} 上传进度`"></progress>
|
||||
<p v-if="item.error" class="mt-1 break-words text-xs text-red-600 dark:text-red-400">{{ item.error }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="content-panel home-panel-compact space-y-2.5">
|
||||
@@ -226,8 +263,21 @@ const clientUploadLimits = computed(() => uploadLimits(uploadConfig.value));
|
||||
const uploadAccept = computed(() => allowedRasterTypes(uploadConfig.value).join(','));
|
||||
const uploadingCount = ref(0);
|
||||
const uploadProgress = ref(0);
|
||||
const uploadQueue = ref([]);
|
||||
const queueBusy = ref(false);
|
||||
const queueHasPending = computed(() => uploadQueue.value.some(item => item.status === 'queued' || (item.status === 'failed' && !directEnabled())));
|
||||
const queueHasFinished = computed(() => uploadQueue.value.some(item => item.status === 'success' || item.status === 'failed'));
|
||||
const queueHasRemovable = computed(() => uploadQueue.value.some(item => item.status !== 'uploading'));
|
||||
const recentImages = ref([]);
|
||||
const fileInput = ref(null);
|
||||
const createPreviewUrl = file => {
|
||||
try { return URL.createObjectURL(file); } catch { return ''; }
|
||||
};
|
||||
const revokeQueuePreview = item => {
|
||||
if (!item?.previewUrl) return;
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
item.previewUrl = '';
|
||||
};
|
||||
|
||||
// Folder state belongs to this mounted account only; never persist upload destinations.
|
||||
const route = useRoute();
|
||||
@@ -302,6 +352,51 @@ const formatFileSize = (bytes) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const queueStatusLabel = status => ({ queued: '等待上传', uploading: '正在传输', success: '上传完成', failed: '上传失败' }[status] || '等待上传');
|
||||
const queueStatusIcon = status => ({ queued: 'ri-time-line text-slate-400', uploading: 'ri-loader-4-line animate-spin text-blue-600', success: 'ri-checkbox-circle-fill text-emerald-600', failed: 'ri-error-warning-fill text-red-600' }[status] || 'ri-time-line text-slate-400');
|
||||
const queueStatusClass = status => ({ queued: 'text-slate-500 dark:text-slate-400', uploading: 'text-blue-700 dark:text-blue-300', success: 'text-emerald-700 dark:text-emerald-300', failed: 'text-red-700 dark:text-red-300' }[status] || 'text-slate-500');
|
||||
const enqueueFiles = files => {
|
||||
if (queueBusy.value || isUploading.value) return;
|
||||
const available = Math.max(0, clientUploadLimits.value.max_upload_files - uploadQueue.value.length);
|
||||
const accepted = files.slice(0, available);
|
||||
if (files.length > accepted.length) Message.warning(`队列最多保留 ${clientUploadLimits.value.max_upload_files} 个文件,已加入 ${accepted.length} 个`);
|
||||
uploadQueue.value.push(...accepted.map(file => ({ id: crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`, file, previewUrl: createPreviewUrl(file), status: 'queued', percent: null, loaded: 0, total: file.size, error: '' })));
|
||||
};
|
||||
const removeQueuedFile = id => {
|
||||
if (queueBusy.value) return;
|
||||
const removed = uploadQueue.value.find(item => item.id === id);
|
||||
revokeQueuePreview(removed);
|
||||
uploadQueue.value = uploadQueue.value.filter(item => item.id !== id);
|
||||
};
|
||||
const clearFinishedQueue = () => {
|
||||
if (queueBusy.value) return;
|
||||
const removed = uploadQueue.value.filter(item => item.status === 'success' || item.status === 'failed');
|
||||
removed.forEach(revokeQueuePreview);
|
||||
uploadQueue.value = uploadQueue.value.filter(item => item.status !== 'success' && item.status !== 'failed');
|
||||
};
|
||||
const clearUploadQueue = () => {
|
||||
if (queueBusy.value) return;
|
||||
uploadQueue.value.forEach(revokeQueuePreview);
|
||||
uploadQueue.value = [];
|
||||
};
|
||||
const startQueuedUpload = async () => {
|
||||
if (queueBusy.value || !storageConfigLoaded.value) return;
|
||||
const items = uploadQueue.value.filter(item => item.status === 'queued' || (item.status === 'failed' && !directEnabled()));
|
||||
if (!items.length) return;
|
||||
queueBusy.value = true;
|
||||
items.forEach(item => { item.status = 'queued'; item.error = ''; item.percent = null; item.loaded = 0; });
|
||||
try {
|
||||
await uploadFiles(items.map(item => item.file), (file, patch, clientId) => {
|
||||
const item = (clientId && uploadQueue.value.find(entry => entry.clientId === clientId)) || uploadQueue.value.find(entry => entry.file === file && entry.status !== 'success');
|
||||
if (item) {
|
||||
if (clientId) item.clientId = clientId;
|
||||
Object.assign(item, patch);
|
||||
if (patch.task?.image) item.image = { ...patch.task.image };
|
||||
}
|
||||
});
|
||||
} finally { queueBusy.value = false; }
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
@@ -495,6 +590,7 @@ const { handleDragOver, handleDragEnter, handleDragLeave, handleDrop, triggerFil
|
||||
API_BASE_URL, isDragOver, isUploading, fileInput, resumeTask, handleResumeFile, clientUploadLimits,
|
||||
uploadConfig, storageConfigLoaded, directEnabled, startDirectFiles, legacyUploading, uploadingCount,
|
||||
uploadProgress, getUploadFolderId, multiStorageSync, selectedBucket, recentImages, loadRecentImages,
|
||||
onFilesReady: enqueueFiles,
|
||||
isDisposed: () => disposed,
|
||||
onSingleUpload: showUploadResult,
|
||||
});
|
||||
@@ -700,6 +796,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
uploadQueue.value.forEach(revokeQueuePreview);
|
||||
disposeDirectTasks();
|
||||
cancelLegacyUpload();
|
||||
if (syncPollTimer) clearTimeout(syncPollTimer);
|
||||
|
||||
@@ -88,6 +88,7 @@ const activeSettingsTab = ref('storage')
|
||||
const systemSettings = ref({})
|
||||
const presetBuckets = ref([])
|
||||
const mySettingPerms = ref([])
|
||||
const isAdmin = computed(() => mySettingPerms.value.includes('setting:admin'))
|
||||
const settingsNotice = ref('正在加载设置…')
|
||||
// Permission authority is the Cookie-authenticated backend, never a stale localStorage snapshot.
|
||||
const settingsState = ref('loading')
|
||||
@@ -239,7 +240,7 @@ const generateRandomGraph = createRandomGraphSettings();
|
||||
onBeforeUnmount(() => { disposeDialogs(); generateRandomGraph.dispose() })
|
||||
|
||||
// 6. 初始化
|
||||
provide(settingsContext, { systemSettings, activeSettingsTab, activeSettingsTabLabel, presetBuckets, supportsPublicImageDomain, hasPublicImageDomain, publicImageDomainInputDisabled, publicImageDomainHint, handleFieldBlur, handleSelectChange, handleSwitchChange, generatedApiToken, tokenGenerating, generateApiToken, generateRandomGraph })
|
||||
provide(settingsContext, { systemSettings, activeSettingsTab, activeSettingsTabLabel, presetBuckets, supportsPublicImageDomain, hasPublicImageDomain, publicImageDomainInputDisabled, publicImageDomainHint, handleFieldBlur, handleSelectChange, handleSwitchChange, generatedApiToken, tokenGenerating, generateApiToken, generateRandomGraph, isAdmin })
|
||||
onMounted(async () => {
|
||||
await fetchSystemSettings()
|
||||
if (!canReadSettings.value || settingsState.value === 'error') return
|
||||
|
||||
Reference in new issue
Block a user