10 Commits
Author SHA1 Message Date
gary 8cb726e3ad Merge remote-tracking branch 'upstream/main' 2026-10-28 13:13:51 +08:00
gary 4fdf3bace7 chore: set compose container name 2026-10-27 23:55:55 +08:00
gary eec7eef96d feat: support optional Traefik compose deployment 2026-10-27 23:35:14 +08:00
gary 3581f62139 feat: add batch upload queue with per-file progress 2026-10-27 23:09:28 +08:00
gary b6cfdfeca7 feat: make image dimension limits configurable 2026-10-27 15:50:53 +08:00
onexru 2663ab0a38 Update README.md 2026-10-27 15:13:28 +08:00
gary caf53f007b fix: improve release version reporting 2026-10-27 13:00:37 +08:00
gary 79580f4568 feat: support referer for URL image uploads 2026-10-27 12:55:00 +08:00
onexru f752e60b4c Merge pull request #44 from Almighty-ap/fix/telegram-webp-upload
fix(storage): 禁用 Telegram 上传内容探测,避免 WebP 被误识别为贴纸导致 file_id 丢失
2026-10-27 09:32:41 +08:00
AlmightyMutsum1 3cc2d8679c fix(storage): 禁用 Telegram 上传内容探测,避免 WebP 被误识别为贴纸导致 file_id 丢失
向 Telegram API 发送 sendDocument 时显式传入 disable_content_type_detection: true。关闭加密存储后,未加密的裸 .webp 文件会被 Telegram 服务端自动嗅探为 sticker,返回结构中不含 document.file_id 导致驱动判定上传失败。禁用内容探测后可确保所有格式文件强制作为标准 document 保存。
2026-10-27 04:12:27 +08:00
35 changed files with 579 additions and 115 deletions

No files matched your search

+19
View File
@@ -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
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 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
+13 -2
View File
@@ -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` 留空则随机生成 |
+4
View File
@@ -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"
)
+9 -5
View File
@@ -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
}
+17 -3
View File
@@ -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")
}
}
+3 -3
View File
@@ -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
+7
View File
@@ -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",
+5 -1
View File
@@ -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 {
+5 -1
View File
@@ -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, "未知的设置项"))
+1 -1
View File
@@ -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":
+30 -1
View File
@@ -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
View File
@@ -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) {
+12 -10
View File
@@ -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.
+10 -8
View File
@@ -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"` // 图片直链域名(用于非本地存储直接访问)
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+3
View File
@@ -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
+33 -13
View File
@@ -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)
}
}
+10 -5
View File
@@ -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)
+11 -3
View File
@@ -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)
}
+24 -7
View File
@@ -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)
+22
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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,
+11 -4
View File
@@ -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,
+43 -27
View File
@@ -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 });
+22 -5
View File
@@ -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
+99 -2
View File
@@ -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);
+2 -1
View File
@@ -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