Document the Gitea fork and its upstream, align container and Actions instructions, and describe the current feature set. Improve storage connection diagnostics and make API failures easier to understand in the UI.
This commit is contained in:
1 parent
70430c588d
commit
b875643021
6 files changed
+138
-53
No files matched your search
@@ -16,19 +16,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/onexru/oneimg?style=flat-square" />
|
||||
<img src="https://img.shields.io/github/forks/onexru/oneimg?style=flat-square" />
|
||||
<img src="https://img.shields.io/github/issues/onexru/oneimg?style=flat-square" />
|
||||
<img src="https://img.shields.io/github/last-commit/onexru/oneimg?style=flat-square" />
|
||||
<img src="https://img.shields.io/github/repo-size/onexru/oneimg?style=flat-square" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://demo.eta.im">在线演示</a> ·
|
||||
<a href="https://www.tr0.cn/oneimgapi/">API 文档</a> ·
|
||||
<a href="https://www.tr0.cn">作者主页</a> ·
|
||||
<a href="https://www.cv0.cn/donate">赞助支持</a> ·
|
||||
<a href="https://qm.qq.com/q/lzT9IDkKVG">QQ 群</a>
|
||||
<a href="https://git.bytevibe.dev/gary/oneimg">Gitea 仓库</a> ·
|
||||
<a href="https://github.com/onexru/oneimg">上游项目</a> ·
|
||||
<a href="https://git.bytevibe.dev/gary/oneimg/issues">问题反馈</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -46,7 +36,11 @@
|
||||
|
||||
## 项目简介
|
||||
|
||||
OneImg 是一个功能完整的自托管图床与图片管理系统。它的核心目标不只是「能传图」,而是将**上传、管理、访问、同步和存储切换**整合成一套顺手的完整工作流。
|
||||
OneImg 是一个功能完整的自托管图床与图片管理系统。本仓库基于上游项目 [onexru/oneimg](https://github.com/onexru/oneimg),用于个人定制、功能扩展和持续维护;上游项目的许可证与版权信息请以其仓库为准。
|
||||
|
||||
当前仓库保留 OneImg 的核心能力,并结合实际部署需求持续扩展,例如 Gitea 集成、多存储同步、R2 直传、数据库 TLS 配置、初始密码安全处理,以及本地存储和外部存储访问的兼容性与安全性改进。具体变更以本仓库提交记录为准。
|
||||
|
||||
它的核心目标不只是「能传图」,而是将**上传、管理、访问、同步和存储切换**整合成一套顺手的完整工作流。
|
||||
|
||||
相比传统图床方案,OneImg 在以下几个维度上做了更深的设计:
|
||||
|
||||
@@ -68,6 +62,7 @@ OneImg 是一个功能完整的自托管图床与图片管理系统。它的核
|
||||
- **Ctrl+V 剪贴板粘贴**:截图后直接粘贴即传,无需额外操作
|
||||
- **拖拽上传**:将图片拖入上传区域即可
|
||||
- **批量选择**:一次选择多张图片,配合实时进度条
|
||||
- **R2 直传**:支持浏览器向 Cloudflare R2 直传,并在服务端完成校验与入库
|
||||
- **URL 远程上传**:输入图片链接,服务端拉取后入库
|
||||
- 支持格式:JPEG / PNG / GIF / WebP(按实际图片字节校验,不信任浏览器声明的 MIME)
|
||||
|
||||
@@ -112,7 +107,10 @@ S3 存储默认启用 **Path Style** 请求(`Endpoint/Bucket/Object`),适
|
||||
提供完整的 Web 管理界面,所有操作集中在一处完成:
|
||||
|
||||
- **图片管理**:列表浏览、详情查看、标签分类、批量删除
|
||||
- **用户与权限**:多用户体系,按角色分配操作权限
|
||||
- **图库整理**:创建文件夹、移动图片,并按标签筛选
|
||||
- **用户与权限**:多用户体系,按角色与权限分配存储访问能力
|
||||
- **外部登录**:支持 OIDC 与 CAS 单点登录
|
||||
- **API 凭据**:生成和管理 API Token,供外部程序调用上传接口
|
||||
- **存储管理**:各存储后端状态监控、容量统计、同步状态追踪
|
||||
- **系统设置**:全局配置、安全策略、上传限制等
|
||||
- **数据概览**:仪表板实时展示上传量、存储用量等关键指标
|
||||
@@ -151,7 +149,7 @@ S3 存储默认启用 **Path Style** 请求(`Endpoint/Bucket/Object`),适
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
git clone https://github.com/onexru/oneimg.git
|
||||
git clone https://git.bytevibe.dev/gary/oneimg.git
|
||||
cd oneimg
|
||||
docker compose up -d
|
||||
docker compose exec oneimg cat /app/data/.initial_admin_password
|
||||
@@ -159,15 +157,14 @@ docker compose exec oneimg cat /app/data/.initial_admin_password
|
||||
|
||||
### Gitea 容器镜像
|
||||
|
||||
仓库内的 Gitea Actions 工作流在版本标签(`v*`)或手动触发时构建 `linux/amd64` 和
|
||||
`linux/arm64` 镜像,并推送到 Gitea Container Registry。请在仓库设置以下 Secrets:
|
||||
仓库内的 Gitea Actions 工作流在推送版本标签(`v*`)或手动触发时构建 `linux/amd64` 和
|
||||
`linux/arm64` 镜像,并推送到 Gitea Container Registry。请在仓库 Actions Secrets 中设置:
|
||||
|
||||
- `GITEA_USERNAME`:可推送镜像的 Gitea 用户名
|
||||
- `GITEA_TOKEN`:该用户的包写入令牌
|
||||
- `REGISTRY_USERNAME`:可推送镜像的 Gitea 用户名
|
||||
- `REGISTRY_TOKEN`:该用户的包写入令牌
|
||||
|
||||
默认注册表为当前项目使用的 `git.bytevibe.dev`。如果部署在另一台 Gitea,添加仓库变量
|
||||
`GITEA_REGISTRY` 覆盖注册表主机名。镜像名称为 `${GITEA_REGISTRY}/${仓库路径}`,例如
|
||||
`git.bytevibe.dev/gary/oneimg`。
|
||||
默认注册表为 `git.bytevibe.dev`。如需覆盖,设置仓库 Actions Variable `GITEA_REGISTRY`。
|
||||
镜像标签为 `latest` 和触发构建的版本标签,例如 `git.bytevibe.dev/gary/oneimg:latest`。
|
||||
|
||||
启动后访问 `http://localhost:8080`
|
||||
|
||||
@@ -181,7 +178,7 @@ docker run -d \
|
||||
-v /data/oneimg:/app/data \
|
||||
-v /data/oneimg-uploads:/app/uploads \
|
||||
--restart unless-stopped \
|
||||
onexru/oneimg-oneimg
|
||||
git.bytevibe.dev/gary/oneimg:latest
|
||||
|
||||
# 查看首次启动生成的随机管理员密码
|
||||
docker exec oneimg cat /app/data/.initial_admin_password
|
||||
@@ -189,15 +186,12 @@ docker exec oneimg cat /app/data/.initial_admin_password
|
||||
|
||||
### 初始账号与密码
|
||||
|
||||
> **版本差异**:`v3.5.5` 及更早版本,初始管理员密码固定为 `123456`;从 `v3.5.6` 起改为首次启动随机生成。
|
||||
|
||||
```text
|
||||
账号:admin(由 DEFAULT_USER 决定)
|
||||
密码:v3.5.6 起首次启动自动生成,见 data/.initial_admin_password
|
||||
v3.5.5 及更早版本固定为 123456
|
||||
密码:首次启动自动生成,见 data/.initial_admin_password
|
||||
```
|
||||
|
||||
**v3.5.6 及之后**(随机密码)的生成规则:
|
||||
默认 `DEFAULT_PASS` 留空时,初始管理员密码按以下规则生成:
|
||||
|
||||
| `DEFAULT_PASS` | 行为 |
|
||||
|---|---|
|
||||
@@ -208,10 +202,6 @@ docker exec oneimg cat /app/data/.initial_admin_password
|
||||
- 密码**不会**出现在启动日志里,必须读取该文件获取。
|
||||
- 登录后请立即修改密码,并删除该文件。
|
||||
|
||||
**v3.5.5 及更早版本**:内置的 `.env` 模板为 `DEFAULT_PASS=123456`,首次启动直接以该值创建管理员,**不存在** `data/.initial_admin_password` 文件,日志里也不会提示初始密码。请用 `admin` / `123456` 登录后立即修改密码。
|
||||
|
||||
> ⚠️ 从旧版本升级时,如果你保留了旧的 `.env` 文件,其中的 `DEFAULT_PASS=123456` 会被继续当作初始密码使用(仅影响尚未创建过管理员的空库)。新版本仓库提供的模板已改为留空,建议删除该行或改成强密码。
|
||||
|
||||
---
|
||||
|
||||
## 部署说明
|
||||
@@ -227,10 +217,10 @@ docker exec oneimg cat /app/data/.initial_admin_password
|
||||
|
||||
| 目录 | 用途 |
|
||||
|------|------|
|
||||
| `./data` | 数据库、系统配置与自动生成的密钥(`.session_secret`、`.config_secret`) |
|
||||
| `./data` | 数据库、系统配置与自动生成的密钥和初始管理员密码文件 |
|
||||
| `./uploads` | 本地上传文件 |
|
||||
|
||||
> 只要 `./data` 卷保留,`SESSION_SECRET` / `CONFIG_SECRET` 留空时会自动生成并持久化到卷内,重启进程或重建容器都不会导致用户掉线、也不会让已加密的内容无法解密。反过来说,删除 `./data` 会同时丢失这些密钥。
|
||||
> 只要 `./data` 卷保留,`SESSION_SECRET` / `CONFIG_SECRET` 留空时会自动生成并持久化到卷内,重启进程或重建容器都不会导致用户掉线、也不会让已加密的内容无法解密。删除 `./data` 会同时丢失数据库和这些密钥。
|
||||
|
||||
支持三种数据库:
|
||||
1. sqlite (默认)
|
||||
@@ -280,7 +270,7 @@ docker run -d --name oneimg -p 8080:8080 \
|
||||
-e TRUSTED_PROXIES=172.17.0.0/16 \
|
||||
-v /data/oneimg:/app/data \
|
||||
-v /data/oneimg-uploads:/app/uploads \
|
||||
onexru/oneimg-oneimg
|
||||
git.bytevibe.dev/gary/oneimg:latest
|
||||
```
|
||||
|
||||
**优先级**:编排注入的环境变量 > 容器内自动生成的 `/app/.env`。
|
||||
@@ -311,11 +301,6 @@ docker run -d --name oneimg -p 8080:8080 \
|
||||
|
||||
---
|
||||
|
||||
## 支持作者
|
||||
## 反馈与贡献
|
||||
|
||||
项目全程免费开源、持续稳定维护,若本项目对你有帮助,欢迎点亮 ⭐ Star 支持开源,也可通过[打赏通道](https://www.cv0.cn/donate)助力作者持续优化迭代!
|
||||
|
||||
欢迎加入官方技术交流群,反馈使用问题、提出优化建议、交流部署与二次开发经验!
|
||||
|
||||
<!-- Verified on DartNode: DNOS-F6A1ACA1 -->
|
||||
[](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
|
||||
本仓库是基于 [onexru/oneimg](https://github.com/onexru/oneimg) 的定制扩展版本。欢迎在 [Gitea Issues](https://git.bytevibe.dev/gary/oneimg/issues) 提交与本仓库相关的问题或功能建议,也欢迎通过 Pull Request 贡献代码。需要了解上游实现或原始版本时,请访问 [上游仓库](https://github.com/onexru/oneimg)。
|
||||
@@ -33,6 +33,8 @@ const bucketConnectionTestTimeout = 25 * time.Second
|
||||
|
||||
var bucketConnectionTestSlots = make(chan struct{}, 4)
|
||||
|
||||
var errStorageBucketNotFound = errors.New("存储桶不存在")
|
||||
|
||||
var bucketConfigKeys = map[string][]string{
|
||||
"s3": {"s3_endpoint", "s3_access_key", "s3_secret_key", "s3_bucket", "s3_force_path_style"},
|
||||
"r2": {"r2_endpoint", "r2_access_key", "r2_secret_key", "r2_bucket", "r2_cdn_domain", "r2_cdn_mode"},
|
||||
@@ -62,7 +64,7 @@ func TestBucketConnection(c *gin.Context) {
|
||||
bucket, err := buildBucketConnectionCandidate(params)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if errors.Is(err, errStorageBucketNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, result.Error(status, err.Error()))
|
||||
@@ -96,9 +98,9 @@ func buildBucketConnectionCandidate(params map[string]any) (models.Buckets, erro
|
||||
}
|
||||
if err := db.DB.First(&existing, bucketID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return existing, fmt.Errorf("存储桶不存在: %w", err)
|
||||
return existing, errStorageBucketNotFound
|
||||
}
|
||||
return existing, fmt.Errorf("读取存储桶失败: %w", err)
|
||||
return existing, errors.New("无法读取存储桶配置,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +434,7 @@ func sanitizeStorageError(err error, config map[string]any) string {
|
||||
message = strings.ReplaceAll(message, secret, "***")
|
||||
}
|
||||
}
|
||||
message = explainStorageConnectionError(err, message)
|
||||
runes := []rune(message)
|
||||
if len(runes) > 500 {
|
||||
message = string(runes[:500]) + "..."
|
||||
@@ -439,6 +442,28 @@ func sanitizeStorageError(err error, config map[string]any) string {
|
||||
return message
|
||||
}
|
||||
|
||||
func explainStorageConnectionError(err error, message string) string {
|
||||
lower := strings.ToLower(message)
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded),
|
||||
strings.Contains(lower, "i/o timeout"), strings.Contains(lower, "timed out"),
|
||||
strings.Contains(lower, "timeout"):
|
||||
return "连接超时。请检查服务器地址、端口、防火墙和服务状态。"
|
||||
case strings.Contains(lower, "no such host"), strings.Contains(lower, "name or service not known"),
|
||||
strings.Contains(lower, "temporary failure in name resolution"):
|
||||
return "无法解析服务器地址。请检查主机名和 DNS 设置。"
|
||||
case strings.Contains(lower, "connection refused"):
|
||||
return "服务器拒绝连接。请确认端口正确且对应服务正在运行。"
|
||||
case strings.Contains(lower, "network is unreachable"), strings.Contains(lower, "no route to host"):
|
||||
return "服务器网络不可达。请检查网络路由、防火墙和目标地址。"
|
||||
case strings.Contains(lower, "x509:"), strings.Contains(lower, "certificate verify failed"),
|
||||
strings.Contains(lower, "tls: failed to verify"):
|
||||
return "TLS 证书验证失败。请检查 HTTPS 地址、证书有效期和证书链。"
|
||||
default:
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
func translateS3Error(err error) string {
|
||||
var minioErr minio.ErrorResponse
|
||||
if errors.As(err, &minioErr) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExplainStorageConnectionError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"timeout", context.DeadlineExceeded, "连接超时。请检查服务器地址、端口、防火墙和服务状态。"},
|
||||
{"dns", errors.New("lookup storage.invalid: no such host"), "无法解析服务器地址。请检查主机名和 DNS 设置。"},
|
||||
{"refused", errors.New("dial tcp 127.0.0.1:9000: connect: connection refused"), "服务器拒绝连接。请确认端口正确且对应服务正在运行。"},
|
||||
{"network", errors.New("network is unreachable"), "服务器网络不可达。请检查网络路由、防火墙和目标地址。"},
|
||||
{"certificate", errors.New("x509: certificate has expired"), "TLS 证书验证失败。请检查 HTTPS 地址、证书有效期和证书链。"},
|
||||
{"provider detail", errors.New("bucket name does not exist"), "bucket name does not exist"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := explainStorageConnectionError(tt.err, tt.err.Error()); got != tt.want {
|
||||
t.Fatalf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,58 @@
|
||||
const statusMessages = {
|
||||
400: '提交的信息有误,请检查填写内容后重试',
|
||||
401: '登录已过期,请重新登录',
|
||||
403: '没有权限执行此操作,请联系管理员',
|
||||
404: '请求的内容不存在,可能已被删除或移动',
|
||||
408: '请求等待时间过长,请检查网络后重试',
|
||||
409: '内容已发生变化或与现有数据冲突,请刷新后重试',
|
||||
413: '文件或请求内容过大,请缩小文件或减少一次操作的数量',
|
||||
415: '不支持此文件格式,请检查文件类型',
|
||||
422: '提交内容无法处理,请检查填写内容',
|
||||
429: '操作过于频繁,请稍后重试',
|
||||
500: '服务器处理请求时出错,请稍后重试',
|
||||
502: '服务器暂时无法连接上游服务,请稍后重试',
|
||||
503: '服务暂时不可用,请稍后重试',
|
||||
504: '上游服务响应超时,请稍后重试'
|
||||
};
|
||||
|
||||
const genericMessages = new Set(['请求失败', '操作失败', '服务器错误', '未知错误']);
|
||||
|
||||
/** Replace browser/transport jargon wherever an error reaches a toast. */
|
||||
export function userFacingMessage(value, fallback = '操作未完成,请重试') {
|
||||
const message = typeof value === 'string' ? value.trim() : '';
|
||||
if (!message) return fallback;
|
||||
if (/failed to fetch|networkerror|load failed|err_network|网络异常/i.test(message)) {
|
||||
return message.replace(/failed to fetch|networkerror|load failed|err_network|网络异常/ig,
|
||||
'无法连接服务器,请检查网络连接后重试');
|
||||
}
|
||||
if (/the operation was aborted|aborterror|请求已取消/i.test(message)) {
|
||||
return message.replace(/the operation was aborted|aborterror/ig, '请求已取消');
|
||||
}
|
||||
if (/timed out|timeout|超时/i.test(message)) {
|
||||
return message.replace(/(?:the )?operation timed out|request timeout|timeout|timed out/ig,
|
||||
'请求超时,请检查网络或服务状态后重试');
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Respect the HTTP status and the OneImg {code,message,data} envelope together. */
|
||||
export function apiMessage(status, result, fallback = '请求失败,请重试') {
|
||||
const message = typeof result?.message === 'string' ? result.message.trim() : '';
|
||||
if (message) return message;
|
||||
return ({ 401: '登录已过期,请重新登录', 403: '没有权限执行此操作,请联系管理员',
|
||||
413: '请求或文件超过服务器限制', 415: '不支持此图片格式,SVG 不允许上传',
|
||||
429: '请求过于频繁,请稍后重试', 503: '服务暂时不可用,请稍后重试' })[status] || fallback;
|
||||
if (message && !genericMessages.has(message)) return userFacingMessage(message, fallback);
|
||||
const specific = statusMessages[status];
|
||||
if (status === 429) {
|
||||
const raw = result?.retry_after;
|
||||
const seconds = Number.isFinite(Number(raw)) ? Number(raw) : 0;
|
||||
return `操作过于频繁,请${seconds > 0 ? ` ${seconds} 秒后` : '稍后'}重试`;
|
||||
}
|
||||
return specific || (message && !genericMessages.has(message) ? userFacingMessage(message, fallback) : fallback);
|
||||
}
|
||||
export async function readApiResponse(response, fallback) {
|
||||
let result;
|
||||
try { result = await response.json(); } catch { result = {}; }
|
||||
if (response.status === 429 && !result?.retry_after) {
|
||||
result.retry_after = response.headers?.get('Retry-After') || '';
|
||||
}
|
||||
if (!response.ok || result?.code !== 200) {
|
||||
const error = new Error(apiMessage(response.status, result, response.ok && !result?.code ? '服务器响应格式无效,请重试' : fallback));
|
||||
error.status = response.status;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FEEDBACK_STYLE, applyFeedbackStyles } from './feedbackStyles.js';
|
||||
import { OVERLAY_LAYERS } from './overlay.js';
|
||||
import { userFacingMessage } from './apiFeedback.js';
|
||||
|
||||
class Message {
|
||||
// 默认配置
|
||||
@@ -497,7 +498,7 @@ Message.warning = function(message, options = {}) {
|
||||
Message.error = function(message, options = {}) {
|
||||
return this.show({
|
||||
type: 'error',
|
||||
message,
|
||||
message: userFacingMessage(message),
|
||||
...options
|
||||
});
|
||||
};
|
||||
@@ -517,4 +518,4 @@ if (!window.showToast) {
|
||||
};
|
||||
}
|
||||
|
||||
export default Message;
|
||||
export default Message;
|
||||
@@ -76,6 +76,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { userFacingMessage } from '@/utils/apiFeedback.js'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
@@ -128,7 +129,7 @@ const loadStats = async () => {
|
||||
stats.value = { ...stats.value, ...(result.data || {}) }
|
||||
} catch (error) {
|
||||
console.error('加载统计数据错误:', error)
|
||||
showNotification('加载统计数据失败: ' + error.message, 'error')
|
||||
showNotification(userFacingMessage(`加载统计数据失败:${error.message}`, '加载统计数据失败,请检查网络后重试'), 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user