diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml index 889c9a0..42adf52 100644 --- a/.gitea/workflows/lint.yaml +++ b/.gitea/workflows/lint.yaml @@ -13,10 +13,11 @@ jobs: node-version: '24' - uses: pnpm/action-setup@v4 with: - cache: true + version: latest + cache: true - name: Install dependencies run: | - pnpm install --frozen-lockfile + pnpm install --frozen-lockfile - name: Run frontend lint run: pnpm lint:front diff --git a/.gitignore b/.gitignore index f4907fb..8ccc0bf 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ logs .env .env.* !.env.example -config.yaml +/config.yaml # Serwist /front/public/sw* @@ -31,3 +31,6 @@ config.yaml # backend **/uploads/** **/tmp/** + +# worker +pkg/mail/out/ diff --git a/Dockerfile b/Dockerfile index e1bfb9e..76b04a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,23 @@ FROM node:22-alpine AS front-base +WORKDIR /app # Install dependencies only when needed -FROM front-base AS front-deps -RUN apk add --no-cache gcompat -WORKDIR /app -COPY . . -RUN corepack enable pnpm && pnpm i && pnpm --filter=015-front deploy dist --legacy - - FROM front-base AS front-builder -WORKDIR /app -COPY --from=front-deps /app/dist/ . -RUN corepack enable pnpm && pnpm build +RUN apk add --no-cache gcompat +ENV CI=true +ENV NODE_OPTIONS="--max-old-space-size=4096" +COPY . . +RUN corepack enable pnpm && pnpm i && pnpm --filter=015-front build && pnpm --dir pkg/mail export -FROM golang:1.25.5 AS backend-builder +FROM golang:1.26.3 AS backend-builder WORKDIR /app # Workspace and module manifests for cache COPY go.work go.work.sum ./ COPY backend/ ./backend/ COPY worker/ ./worker/ COPY pkg/ ./pkg/ +# Inject built email templates so Go can embed them +COPY --from=front-builder /app/pkg/mail/out/ ./pkg/mail/out/ RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct && \ go mod download # Build from workspace root so pkg/utils, pkg/models, pkg/services resolve @@ -29,7 +27,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o backend-bin ./backend FROM front-base AS runner ARG VERSION ARG BUILD_TIME -WORKDIR /app RUN apk add --no-cache curl openssl ENV NODE_ENV production @@ -37,7 +34,7 @@ RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nuxtjs # Only `.output` folder is needed from the build stage -COPY --from=front-builder --chown=nuxtjs:nodejs /app/.output/ ./ +COPY --from=front-builder --chown=nuxtjs:nodejs /app/front/.output/ ./ COPY --from=backend-builder /app/backend-bin /bin/backend COPY 015.sh /app/015.sh diff --git a/README-zh.md b/README-zh.md index 2ef09cd..4f77ef7 100644 --- a/README-zh.md +++ b/README-zh.md @@ -41,16 +41,16 @@ ## 📸 截图预览 -| 选择文件上传页面 | 输入文本上传页面 | -|---------------------------------------|-----------------------------------------------| +| 选择文件上传页面 | 输入文本上传页面 | +| ------------------------- | ------------------------- | | ![](/.github/image/1.png) | ![](/.github/image/2.png) | -| 多选文件上传 | 文件上传进度热力图 | -|------------------------------------------------|---------------------------------------------------| +| 多选文件上传 | 文件上传进度热力图 | +| ------------------------- | ------------------------- | | ![](/.github/image/3.png) | ![](/.github/image/4.png) | -| 文件上传进度条 | 文件上传成功页面 | -|------------------------------------------------|-------------------------------------------------| +| 文件上传进度条 | 文件上传成功页面 | +| ------------------------- | ------------------------- | | ![](/.github/image/5.png) | ![](/.github/image/6.png) | ## 🚀 快速开始 @@ -61,31 +61,15 @@ - config.example.yaml - docker-compose.yml -2. 把config.example.yaml配置完成后改为config.yaml - - -3. 启动 -```bash -docker compose up -d -``` - - -## 🚀 快速开始 - -### Docker - -1. 下载文件 - - config.example.yaml - - docker-compose.yml - -2. 把config.example.yaml配置完成后改为config.yaml - +2. 把 config.example.yaml 配置完成后改为 config.yaml 3. 启动 + ```bash docker compose up -d ``` +4. 访问 `http://localhost:8080` ## 🏗️ 技术架构 @@ -130,15 +114,15 @@ docker compose up -d 015/ ├── front/ # 前端应用 (Vue 3 + Nuxt 3) │ ├── components/ # Vue 组件 -│ │ ├── pages/ # 页面路由 -│ │ ├── composables/ # 组合式函数 -│ │ ├── i18n/ # 国际化文件 -│ │ └── assets/ # 静态资源 -│ └── middleware/ # 中间件 +│ ├── pages/ # 页面路由 +│ ├── composables/ # 组合式函数 +│ ├── i18n/ # 国际化文件 +│ ├── assets/ # 静态资源 +│ ├── plugins/ # Nuxt 插件 +│ └── server/ # 服务端路由 ├── backend/ # 后端服务 (Go + Echo) │ ├── internal/ # 内部包 │ │ ├── controllers/ # 控制器 -│ │ ├── models/ # 数据模型 │ │ ├── services/ # 业务逻辑 │ │ └── utils/ # 工具函数 │ └── middleware/ # 中间件 @@ -147,7 +131,7 @@ docker compose up -d │ │ ├── tasks/ # 任务处理器 │ │ └── utils/ # 工具函数 │ └── middleware/ # 中间件 -└── tmp/ # 临时文件 +└── pkg/ # 公共包 ``` ## 🔧 开发指南 @@ -186,19 +170,19 @@ cd worker && go build -o worker . - 前端计算哈希和秒传 - 并发切片上传 (使用 Web Worker) - 文件上传/文本上传和分享 +- 支持多文件上传 - 上传统计页面 - 多语言支持 - 最大上传限制 - 后端队列系统和 Worker 处理文件 +- 断点续传 (后端计算已上传部分并返回) +- 图片格式转换和压缩 ### 计划功能 🚧 -- 断点续传 (后端计算已上传部分并返回) -- 图片格式转换和压缩 - 图片 OCR 复制 - 文档转 Markdown - 文本翻译/总结 -- 支持上传多文件 ## 🤝 贡献指南 diff --git a/README.md b/README.md index 9d1075a..01d3842 100644 --- a/README.md +++ b/README.md @@ -41,17 +41,34 @@ English | [中文](README-zh.md) ## 📸 Screenshots -| File Selection Upload Page | Text Input Upload Page | -|---------------------------------------|-----------------------------------------------| -| ![](/.github/image/1.png) | ![](/.github/image/2.png) | +| File Selection Upload Page | Text Input Upload Page | +| -------------------------- | ------------------------- | +| ![](/.github/image/1.png) | ![](/.github/image/2.png) | -| Multiple File Upload | Upload Progress Heatmap | -|------------------------------------------------|---------------------------------------------------| -| ![](/.github/image/3.png) | ![](/.github/image/4.png) | +| Multiple File Upload | Upload Progress Heatmap | +| -------------------------- | ------------------------- | +| ![](/.github/image/3.png) | ![](/.github/image/4.png) | -| Upload Progress Bar | Upload Success Page | -|------------------------------------------------|-------------------------------------------------| -| ![](/.github/image/5.png) | ![](/.github/image/6.png) | +| Upload Progress Bar | Upload Success Page | +| -------------------------- | ------------------------- | +| ![](/.github/image/5.png) | ![](/.github/image/6.png) | + +## 🚀 Quick Start + +### Docker + +1. Download files + - config.example.yaml + - docker-compose.yml + +2. Rename config.example.yaml to config.yaml after configuration + +3. Start +```bash +docker compose up -d +``` + +4. Visit `http://localhost:8080` ## 🏗️ Technical Architecture @@ -90,36 +107,21 @@ English | [中文](README-zh.md) - **Redis Cache** - Share information and file metadata caching - **Queue System** - Asynchronous task processing queue -## 🚀 Quick Start - -### Docker - -1. Download files - - config.example.yaml - - docker-compose.yml - -2. Rename config.example.yaml to config.yaml after configuration - -3. Start -```bash -docker compose up -d -``` - ## 📁 Project Structure ``` 015/ ├── front/ # Frontend application (Vue 3 + Nuxt 3) │ ├── components/ # Vue components -│ │ ├── pages/ # Page routes -│ │ ├── composables/ # Composable functions -│ │ ├── i18n/ # Internationalization files -│ │ └── assets/ # Static assets -│ └── middleware/ # Middleware +│ ├── pages/ # Page routes +│ ├── composables/ # Composable functions +│ ├── i18n/ # Internationalization files +│ ├── assets/ # Static assets +│ ├── plugins/ # Nuxt plugins +│ └── server/ # Server-side routes ├── backend/ # Backend service (Go + Echo) │ ├── internal/ # Internal packages │ │ ├── controllers/ # Controllers -│ │ ├── models/ # Data models │ │ ├── services/ # Business logic │ │ └── utils/ # Utility functions │ └── middleware/ # Middleware @@ -128,7 +130,7 @@ docker compose up -d │ │ ├── tasks/ # Task processors │ │ └── utils/ # Utility functions │ └── middleware/ # Middleware -└── tmp/ # Temporary files +└── pkg/ # Shared packages ``` ## 🔧 Development Guide @@ -167,19 +169,19 @@ cd worker && go build -o worker . - Frontend hash calculation and instant transfer - Concurrent chunked upload (using Web Worker) - File upload/text upload and sharing +- Multiple file upload support - Upload statistics page - Multi-language support - Maximum upload limits - Backend queue system and Worker file processing +- Resume upload (backend calculates uploaded parts and returns) +- Image format conversion and compression ### Planned Features 🚧 -- Resume upload (backend calculates uploaded parts and returns) -- Image format conversion and compression - Image OCR copy - Document to Markdown conversion - Text translation/summarization -- Support for multiple file uploads ## 🤝 Contributing diff --git a/backend/go.sum b/backend/go.sum index ef2bb42..f419d80 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -23,7 +23,9 @@ github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pw github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw= +github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -35,17 +37,21 @@ github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -53,10 +59,15 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/controllers/config.go b/backend/internal/controllers/config.go index 30b3b30..9dca08e 100644 --- a/backend/internal/controllers/config.go +++ b/backend/internal/controllers/config.go @@ -10,21 +10,45 @@ import ( "github.com/spf13/cast" ) -func GetConfig(c *echo.Context) error { - featureConfig := u.GetEnvMap("features") - features := lo.FilterMap(lo.Entries(featureConfig), func(e lo.Entry[string, any], _ int) (string, bool) { +var defaultEnabledFeatures = []string{ + "file-share", + "text-share", +} + +func getEnabledKeys(config map[string]any) []string { + return lo.FilterMap(lo.Entries(config), func(e lo.Entry[string, any], _ int) (string, bool) { node, ok := e.Value.(map[string]any) return e.Key, ok && cast.ToBool(node["enabled"]) }) +} + +func GetConfig(c *echo.Context) error { + featureConfig := u.GetEnvMap("features") + defaultFeatureConfig := lo.SliceToMap(defaultEnabledFeatures, func(item string) (string, any) { + return item, map[string]any{ + "enabled": true, + } + }) + + featureConfig = lo.Assign(defaultFeatureConfig, featureConfig) + features := getEnabledKeys(featureConfig) + // textTranslateProviderConfig := u.GetEnvMap("features.text-translate.provider") + // textTranslateProviders := getEnabledKeys(textTranslateProviderConfig) return utils.HTTPSuccessHandler(c, map[string]any{ - "site_title": u.GetEnvMap("site.title"), - "site_desc": u.GetEnvMap("site.desc"), - "site_url": u.GetEnv("site.url"), - "site_icon": u.GetEnvWithDefault("site.icon", "/logo.png"), - "site_bg_url": u.GetEnvWithDefault("site.bg_url", "https://img.fudaoyuan.icu/api/1/random/?scale_min=1.5&webp=true&md=false&format=302"), - "version": u.GetEnvWithDefault("VERSION", "dev"), - "build_time": cast.ToInt(u.GetEnvWithDefault("BUILD_TIME", cast.ToString(time.Now().Unix()))), - "features": features, + "site_title": u.GetEnvMap("site.title"), + "site_desc": u.GetEnvMap("site.desc"), + "site_url": u.GetEnv("site.url"), + "site_icon": u.GetEnvWithDefault("site.icon", "/logo.png"), + "site_bg_url": u.GetEnvWithDefault("site.bg_url", "https://img.fudaoyuan.icu/api/1/random/?scale_min=1.5&webp=true&md=false&format=302"), + "site_enable_bg": cast.ToBool(u.GetEnvWithDefault("site.enable_bg", "true")), + "version": u.GetEnvWithDefault("VERSION", "dev"), + "build_time": cast.ToInt(u.GetEnvWithDefault("BUILD_TIME", cast.ToString(time.Now().Unix()))), + "features": features, + "config": map[string]any{ + // "text-translate": map[string]any{ + // "provider": textTranslateProviders, + // }, + }, }) } diff --git a/backend/internal/controllers/download.go b/backend/internal/controllers/download.go index 5171d40..adbd45b 100644 --- a/backend/internal/controllers/download.go +++ b/backend/internal/controllers/download.go @@ -2,13 +2,17 @@ package controllers import ( "backend/internal/utils" + "context" + "encoding/json" "fmt" "pkg/models" u "pkg/utils" "time" "github.com/golang-jwt/jwt/v5" + "github.com/hibiken/asynq" "github.com/labstack/echo/v5" + "github.com/samber/lo" "github.com/spf13/cast" ) @@ -26,14 +30,13 @@ func DownloadShare(c *echo.Context) error { t, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) { return []byte(u.GetEnv("share.download_secret")), nil }) - if err != nil { - return utils.HTTPErrorHandler(c, err) + if err != nil || !t.Valid { + return utils.HTTPErrorHandler(c, lo.Ternary(err != nil, err, ErrInvalidRequest)) } - if !t.Valid { - return utils.HTTPErrorHandler(c, ErrInvalidRequest) + shareInfo, err := models.GetRedisShareInfo(claims.ShareId) + if err != nil || shareInfo == nil { + return utils.HTTPErrorHandler(c, lo.Ternary(err != nil, err, ErrShareNotFound)) } - shareInfo, _ := models.GetRedisShareInfo(claims.ShareId) - if shareInfo.Type == models.ShareTypeFile { fileInfo, _ := models.GetRedisFileInfo(shareInfo.Data) uploadPath, err := u.GetUploadDirPath() @@ -81,64 +84,75 @@ func VaildateShare(c *echo.Context) error { return utils.HTTPErrorHandler(c, ErrInvalidSharePassword) } } - // 如果下载次数为0,则设置为-1 防止空值问题 - if shareInfo.ViewNum < 1 { - return utils.HTTPErrorHandler(c, ErrInsufficientDownloadQuota) - } - downloadWindow := u.GetEnvWithDefault("share.download_window", "12") - token := jwt.NewWithClaims(jwt.SigningMethodHS256, DownloadShareClaims{ - ShareId: r.ShareId, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(cast.ToDuration(downloadWindow + "h"))), - }, - }) + return u.WithLocker(context.Background(), "015:shareInfoMap:"+r.ShareId, 0, func(ctx context.Context) error { + shareInfo, err := models.GetRedisShareInfo(r.ShareId) + if err != nil || shareInfo == nil { + return utils.HTTPErrorHandler(c, lo.Ternary(err != nil, err, ErrShareNotFound)) + } + if shareInfo.ViewNum < 1 { + return utils.HTTPErrorHandler(c, ErrInsufficientDownloadQuota) + } + downloadWindow := u.GetEnvWithDefault("share.download_window", "12") + token := jwt.NewWithClaims(jwt.SigningMethodHS256, DownloadShareClaims{ + ShareId: r.ShareId, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(cast.ToDuration(downloadWindow + "h"))), + }, + }) - // Sign and get the complete encoded token as a string using the secret - downloadToken, err := token.SignedString([]byte(u.GetEnv("share.download_secret"))) - if err != nil { - return utils.HTTPErrorHandler(c, err) - } - if shareInfo.Type == models.ShareTypeFile { - fileInfo, err := models.GetRedisFileInfo(shareInfo.Data) + // Sign and get the complete encoded token as a string using the secret + downloadToken, err := token.SignedString([]byte(u.GetEnv("share.download_secret"))) if err != nil { return utils.HTTPErrorHandler(c, err) } - if fileInfo == nil { - return utils.HTTPErrorHandler(c, ErrShareFileNotFound) + if shareInfo.Type == models.ShareTypeFile { + fileInfo, err := models.GetRedisFileInfo(shareInfo.Data) + if err != nil { + return utils.HTTPErrorHandler(c, err) + } + if fileInfo == nil { + return utils.HTTPErrorHandler(c, ErrShareFileNotFound) + } + if fileInfo.FileType != models.FileTypeUpload { + return utils.HTTPErrorHandler(c, ErrInvalidShareFileState) + } } - if fileInfo.FileType != models.FileTypeUpload { - return utils.HTTPErrorHandler(c, ErrInvalidShareFileState) + // download_nums 必须放在创建token的时候减掉,不然多线程下载会导致多次减掉 + _, err = models.SetRedisShareInfo(r.ShareId, func(shareInfo *models.RedisShareInfo) *models.RedisShareInfo { + shareInfo.ViewNum -= 1 + return shareInfo + }) + if err != nil { + return utils.HTTPErrorHandler(c, err) } - } - // download_nums 必须放在创建token的时候减掉,不然多线程下载会导致多次减掉 - latestViewNum := shareInfo.ViewNum - 1 - // 如果下载次数为0,则设置为-1 防止空值问题 - if latestViewNum < 1 { - latestViewNum = -1 - } - err = models.SetRedisShareInfo(r.ShareId, models.RedisShareInfo{ - ViewNum: latestViewNum, - }) - if err != nil { - return utils.HTTPErrorHandler(c, err) - } - // 统计分享数 - currentDate := time.Now().Format("2006-01-02") - err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { - stat.DownloadNum += 1 - return stat - }) - if err != nil { - return utils.HTTPErrorHandler(c, err) - } + // 统计分享数 + currentDate := time.Now().Format("2006-01-02") + _, err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { + stat.DownloadNum += 1 + return stat + }) + if err != nil { + return utils.HTTPErrorHandler(c, err) + } - if shareInfo.Type == models.ShareTypeFile { + if len(shareInfo.NotifyEmails) > 0 || len(shareInfo.NotifyWebhooks) > 0 { + payload, err := json.Marshal(map[string]string{ + "share_id": r.ShareId, + "ip": c.RealIP(), + }) + if err == nil { + _, _ = u.GetQueueClient().Enqueue(asynq.NewTask("share:notify", payload)) + } + } + + if shareInfo.Type == models.ShareTypeFile { + return utils.HTTPSuccessHandler(c, map[string]any{ + "token": downloadToken, + }) + } return utils.HTTPSuccessHandler(c, map[string]any{ "token": downloadToken, }) - } - return utils.HTTPSuccessHandler(c, map[string]any{ - "token": downloadToken, }) } diff --git a/backend/internal/controllers/errors.go b/backend/internal/controllers/errors.go index bd290dc..0365056 100644 --- a/backend/internal/controllers/errors.go +++ b/backend/internal/controllers/errors.go @@ -17,7 +17,7 @@ var ( ErrInvalidFileSliceIndex = errors.New("InvalidFileSliceIndex") // 文件切片索引错误 ErrInvalidFileSliceSize = errors.New("InvalidFileSliceSize") // 文件切片大小错误 ErrIncompleteFileSlices = errors.New("IncompleteFileSlices") // 文件切片不完整 - ErrFileMD5Mismatch = errors.New("FileMD5Mismatch") // 文件MD5不一致 + ErrFileHashMismatch = errors.New("FileHashMismatch") // 文件Hash不一致 // 分享相关 ErrShareFileNotFound = errors.New("ShareFileNotFound") // 分享文件不存在 diff --git a/backend/internal/controllers/file.go b/backend/internal/controllers/file.go index 9facf2f..29e6d4e 100644 --- a/backend/internal/controllers/file.go +++ b/backend/internal/controllers/file.go @@ -13,7 +13,6 @@ import ( "time" "github.com/labstack/echo/v5" - "github.com/spf13/cast" ) func CreateUploadTask(c *echo.Context) error { @@ -78,36 +77,33 @@ func CreateUploadTask(c *echo.Context) error { for r.FileSize/ChunkSize > 1000 { ChunkSize *= 2 } - uploadTaskExpire := cast.ToInt64(u.GetEnvWithDefault("upload.remove_expire", "2")) * 3600 - newFileInfo := models.RedisFileInfo{ - FileType: models.FileTypeInit, - FileInfo: models.FileInfo{ + redisFileInfo, err := models.SetRedisFileInfo(fileId, func(fileInfo *models.RedisFileInfo) *models.RedisFileInfo { + fileInfo.FileType = models.FileTypeInit + fileInfo.FileInfo = models.FileInfo{ FileSize: r.FileSize, MimeType: r.MimeType, FileHash: r.FileHash, ChunkSize: ChunkSize, - }, - CreatedAt: time.Now().Unix(), - Expire: uploadTaskExpire, - } - err = models.SetRedisFileInfo(fileId, newFileInfo) + } + return fileInfo + }) if err != nil { return utils.HTTPErrorHandler(c, err) } - err = s.SetFileRemoveTask(fileId, time.Duration(uploadTaskExpire)*time.Second) + err = s.SetFileRemoveTask(fileId, time.Duration(redisFileInfo.Expire)*time.Second) if err != nil { return utils.HTTPErrorHandler(c, err) } return utils.HTTPSuccessHandler(c, map[string]any{ - "size": newFileInfo.FileSize, - "mime_type": newFileInfo.MimeType, - "hash": newFileInfo.FileHash, - "type": newFileInfo.FileType, - "expire": newFileInfo.Expire, + "size": redisFileInfo.FileSize, + "mime_type": redisFileInfo.MimeType, + "hash": redisFileInfo.FileHash, + "type": redisFileInfo.FileType, + "expire": redisFileInfo.Expire, "id": fileId, - "chunk_size": newFileInfo.ChunkSize, + "chunk_size": redisFileInfo.ChunkSize, }) } @@ -216,32 +212,33 @@ func FinishUploadTask(c *echo.Context) error { return utils.HTTPErrorHandler(c, err) } - // 计算文件MD5 + // 计算文件SHA1 file, err := os.Open(mergeFilePath) if err != nil { return utils.HTTPErrorHandler(c, err) } defer file.Close() //nolint:errcheck - file_hash, err := u.GetFileMd5(file) + file_hash, err := u.GetFileSHA1(file) if err != nil || file_hash != fileInfo.FileHash { defer os.Remove(mergeFilePath) //nolint:errcheck if err == nil { - return utils.HTTPErrorHandler(c, ErrFileMD5Mismatch) + return utils.HTTPErrorHandler(c, ErrFileHashMismatch) } return utils.HTTPErrorHandler(c, err) } // 更新文件信息 - err = models.SetRedisFileInfo(r.FileId, models.RedisFileInfo{ - FileType: models.FileTypeUpload, + fileInfo, err = models.SetRedisFileInfo(r.FileId, func(fileInfo *models.RedisFileInfo) *models.RedisFileInfo { + fileInfo.FileType = models.FileTypeUpload + return fileInfo }) if err != nil { return utils.HTTPErrorHandler(c, err) } // 统计 currentDate := time.Now().Format("2006-01-02") - err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { + _, err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { stat.FileSize += fileInfo.FileSize stat.FileNum += 1 return stat diff --git a/backend/internal/controllers/share.go b/backend/internal/controllers/share.go index 010e07a..f2d0219 100644 --- a/backend/internal/controllers/share.go +++ b/backend/internal/controllers/share.go @@ -23,13 +23,16 @@ type CreateShareProps struct { } type ShareConfig struct { - ExpireAt int `json:"expire_time"` // 分钟 - ViewNum int64 `json:"download_nums"` - HasPassword bool `json:"has_password"` - Password string `json:"password"` - HasNotify bool `json:"has_notify"` - NotifyEmail []string `json:"notify_email"` - HasPickupCode bool `json:"has_pickup_code"` + ExpireAt int `json:"expire_time"` // 分钟 + ViewNum int64 `json:"download_nums"` + HasPassword bool `json:"has_password"` + Password string `json:"password"` + HasNotify bool `json:"has_notify"` + NotifyTypes []string `json:"notify_types"` + NotifyEmails []string `json:"notify_emails"` + NotifyWebhooks []models.NotifyWebhook `json:"notify_webhooks"` + Locale string `json:"locale"` + HasPickupCode bool `json:"has_pickup_code"` } func CreateShareInfo(c *echo.Context) error { @@ -73,16 +76,41 @@ func CreateShareInfo(c *echo.Context) error { password = hash } - err = models.SetRedisShareInfo(id, models.RedisShareInfo{ - Data: r.Data, - Type: r.Type, - CreatedAt: time.Now().Unix(), - Owner: owner, - ViewNum: r.Config.ViewNum, - Password: password, - // NotifyEmail: r.Config.NotifyEmail, - FileName: r.FileName, - ExpireAt: ExpireTime.Unix(), + var notifyEmails []string + var notifyWebhooks []models.NotifyWebhook + if r.Config.HasNotify { + hasEmail, hasWebhook := false, false + for _, nt := range r.Config.NotifyTypes { + switch nt { + case "email": + hasEmail = true + case "webhook": + hasWebhook = true + default: + return utils.HTTPErrorHandler(c, ErrInvalidRequest) + } + } + if hasEmail { + notifyEmails = r.Config.NotifyEmails + } + if hasWebhook { + notifyWebhooks = r.Config.NotifyWebhooks + } + } + + _, err = models.SetRedisShareInfo(id, func(shareInfo *models.RedisShareInfo) *models.RedisShareInfo { + shareInfo.Data = r.Data + shareInfo.Type = r.Type + shareInfo.CreatedAt = time.Now().Unix() + shareInfo.Owner = owner + shareInfo.ViewNum = r.Config.ViewNum + shareInfo.Password = password + shareInfo.FileName = r.FileName + shareInfo.NotifyEmails = notifyEmails + shareInfo.NotifyWebhooks = notifyWebhooks + shareInfo.Locale = r.Config.Locale + shareInfo.ExpireAt = ExpireTime.Unix() + return shareInfo }) if err != nil { return utils.HTTPErrorHandler(c, err) @@ -128,7 +156,7 @@ func CreateShareInfo(c *echo.Context) error { // 统计分享数 currentDate := time.Now().Format("2006-01-02") - err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { + _, err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { stat.ShareNum += 1 return stat }) diff --git a/backend/internal/controllers/task.go b/backend/internal/controllers/task.go index 4c3b17c..6fb3d62 100644 --- a/backend/internal/controllers/task.go +++ b/backend/internal/controllers/task.go @@ -13,6 +13,7 @@ import ( var handleTaskMap = map[string]func(c *echo.Context) ([]byte, error){ "image:compress": task.HandleImageCompress, "image:convert": task.HandleImageConvert, + "text:translate": task.HandleTextTranslate, } func CreateTask(c *echo.Context) error { diff --git a/backend/internal/controllers/task/text.go b/backend/internal/controllers/task/text.go new file mode 100644 index 0000000..d1b5b5f --- /dev/null +++ b/backend/internal/controllers/task/text.go @@ -0,0 +1,45 @@ +package task + +import ( + "encoding/json" + + "github.com/labstack/echo/v5" +) + +var validProviders = map[string]bool{ + "google": true, + "microsoft": true, + "deeplx": true, + "deepseek": true, +} + +var validSources = map[string]bool{ + "auto": true, + "zh-CN": true, + "en": true, + "ja": true, + "ko": true, +} + +type TranslateTextRequest struct { + Text string `json:"text"` + Source string `json:"source"` + Target string `json:"target"` + Provider string `json:"provider"` +} + +func HandleTextTranslate(c *echo.Context) ([]byte, error) { + r := new(TranslateTextRequest) + if err := c.Bind(r); err != nil { + return nil, err + } + if r.Text == "" || r.Target == "" || !validProviders[r.Provider] || !validSources[r.Source] { + return nil, ErrInvalidRequest + } + return json.Marshal(map[string]any{ + "text": r.Text, + "source": r.Source, + "target": r.Target, + "provider": r.Provider, + }) +} diff --git a/backend/internal/services/file.go b/backend/internal/services/file.go index 8ff5ad5..68ccafc 100644 --- a/backend/internal/services/file.go +++ b/backend/internal/services/file.go @@ -71,18 +71,13 @@ func MergeFileSlices(fileId string, uploadPath string) (string, error) { if err != nil { return "", fmt.Errorf("打开切片文件失败: %v", err) } - defer sf.Close() //nolint:errcheck - for { - n, err := sf.Read(buffer) - if err == io.EOF { - break - } - if err != nil { - return "", fmt.Errorf("读取切片文件失败: %v", err) - } - if _, err := destFile.Write(buffer[:n]); err != nil { - return "", fmt.Errorf("写入合并文件失败: %v", err) - } + + if _, err := io.CopyBuffer(destFile, sf, buffer); err != nil { + sf.Close() //nolint:errcheck + return "", fmt.Errorf("合并切片文件失败: %v", err) + } + if err := sf.Close(); err != nil { + return "", fmt.Errorf("关闭切片文件失败: %v", err) } } return mergeFilePath, nil diff --git a/backend/internal/utils/http_result.go b/backend/internal/utils/http_result.go index accd1ae..8d10c86 100644 --- a/backend/internal/utils/http_result.go +++ b/backend/internal/utils/http_result.go @@ -6,40 +6,36 @@ import ( "github.com/labstack/echo/v5" ) -type Option interface { - applyTo(*HTTPBaseResponse) -} - type HTTPBaseResponse struct { code int message string data map[string]any } -type HTTPBaseResponseProps func(props *HTTPBaseResponse) error +type HTTPBaseResponseProps func(props *HTTPBaseResponse) -type WithCode int - -func (o WithCode) applyTo(props *HTTPBaseResponse) { - props.code = int(o) +func WithCode(data int) HTTPBaseResponseProps { + return func(props *HTTPBaseResponse) { + props.code = data + } } -type WithMessage string - -func (o WithMessage) applyTo(props *HTTPBaseResponse) { - props.message = string(o) +func WithMessage(data string) HTTPBaseResponseProps { + return func(props *HTTPBaseResponse) { + props.message = data + } } -type WithData map[string]any - -func (o WithData) applyTo(props *HTTPBaseResponse) { - props.data = o +func WithData(data map[string]any) HTTPBaseResponseProps { + return func(props *HTTPBaseResponse) { + props.data = data + } } -func HTTPBaseHandler(c *echo.Context, options ...Option) error { +func HTTPBaseHandler(c *echo.Context, options ...HTTPBaseResponseProps) error { props := HTTPBaseResponse{code: http.StatusOK, message: "success", data: map[string]any{}} for _, option := range options { - option.applyTo(&props) + option(&props) } return c.JSON(props.code, map[string]any{ @@ -50,9 +46,11 @@ func HTTPBaseHandler(c *echo.Context, options ...Option) error { } func HTTPSuccessHandler(c *echo.Context, data map[string]any, options ...HTTPBaseResponseProps) error { - return HTTPBaseHandler(c, WithData(data)) + options = append([]HTTPBaseResponseProps{WithData(data)}, options...) + return HTTPBaseHandler(c, options...) } func HTTPErrorHandler(c *echo.Context, err error, options ...HTTPBaseResponseProps) error { - return HTTPBaseHandler(c, WithMessage(err.Error()), WithCode(http.StatusBadRequest)) + options = append([]HTTPBaseResponseProps{WithMessage(err.Error()), WithCode(http.StatusBadRequest)}, options...) + return HTTPBaseHandler(c, options...) } diff --git a/backend/main.go b/backend/main.go index c74f786..242306f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -18,6 +18,15 @@ func main() { } defer logger.Sync() //nolint:errcheck zap.ReplaceGlobals(logger) + // redis + if err := utils.InitRedis(); err != nil { + logger.Fatal("redis init failed", zap.Error(err)) + panic(err) + } + if err := utils.InitAsynq(); err != nil { + logger.Fatal("asynq init failed", zap.Error(err)) + panic(err) + } e := echo.New() for _, middleware := range middlewares { diff --git a/front/components/About/AboutBaseInfo.vue b/front/components/About/AboutBaseInfo.vue index 6f30756..8379c32 100644 --- a/front/components/About/AboutBaseInfo.vue +++ b/front/components/About/AboutBaseInfo.vue @@ -2,12 +2,15 @@ import { useQuery } from '@tanstack/vue-query' import { Skeleton } from '@/components/ui/skeleton' import getFileSize from '~/lib/getFileSize' -import SparkMD5 from 'spark-md5' import useMyAppConfig from '@/composables/useMyAppConfig' import { useFeatureMeta } from '@/composables/useFeatureMeta' import Progress from '~/components/ui/progress/Progress.vue' import renderI18n from '~/lib/renderI18n' import { I18nT } from 'vue-i18n' +import { calcNativeHash } from '~/lib/calcFileHash' +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' +import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion' +import MarkdownRender from '@/components/MarkdownRender.vue' const { locale } = useI18n() const appConfig = useMyAppConfig() @@ -34,9 +37,12 @@ const { data, isLoading } = useQuery({ }) const { t } = useI18n() -const genUserAvatar = (email: string) => { - return `https://www.gravatar.com/avatar/${SparkMD5.hash(email)}?d=retro` -} +const { state: userAvatar } = useAsyncState(async () => { + if (!data?.value?.email) return null + const buffer = new TextEncoder().encode(data?.value?.email) + const hash = await calcNativeHash(buffer) + return `https://www.gravatar.com/avatar/${hash}?d=retro` +}, null) diff --git a/front/components/Drawer/TextShareDrawer.vue b/front/components/Drawer/TextShareDrawer.vue index f0aaaa5..ccbbf20 100644 --- a/front/components/Drawer/TextShareDrawer.vue +++ b/front/components/Drawer/TextShareDrawer.vue @@ -20,6 +20,15 @@ const actionHandlers: Partial> = { 'text-share': { onClick: () => showDrawer({ render: ({ hide }) => h(TextShareHandle, { ...props, hide }) }), }, + // 'text-translate': { + // onClick: () => + // props.onTextHandle({ + // type: 'text-translate', + // config: { + // source: 'auto', + // }, + // }), + // }, // 'text-image-generate': { // label: '生成配图', icon: LucideImage, className: 'bg-red-300', // onClick: () => { console.log('复制链接') } diff --git a/front/components/Field/FileUploadField.vue b/front/components/Field/FileUploadField.vue index 7ed1695..c0eeac9 100644 --- a/front/components/Field/FileUploadField.vue +++ b/front/components/Field/FileUploadField.vue @@ -3,7 +3,7 @@ import FileUpload from '@/components/FileUpload.vue' import { cx } from 'class-variance-authority' import type { RuleExpression } from 'vee-validate' import Button from '../ui/button/Button.vue' -import { PlusIcon } from 'lucide-vue-next' +import { PlusIcon } from '@lucide/vue' import { isEmpty } from 'lodash-es' const props = defineProps<{ diff --git a/front/components/Field/InputField.vue b/front/components/Field/InputField.vue index d34ccc1..8861754 100644 --- a/front/components/Field/InputField.vue +++ b/front/components/Field/InputField.vue @@ -1,16 +1,20 @@ - + + diff --git a/front/components/Field/InputGroupField.vue b/front/components/Field/InputGroupField.vue new file mode 100644 index 0000000..8cb24c8 --- /dev/null +++ b/front/components/Field/InputGroupField.vue @@ -0,0 +1,37 @@ + + + diff --git a/front/components/Field/KvInputGroupField.vue b/front/components/Field/KvInputGroupField.vue new file mode 100644 index 0000000..bbfebf3 --- /dev/null +++ b/front/components/Field/KvInputGroupField.vue @@ -0,0 +1,102 @@ + + + diff --git a/front/components/Field/SelectField.vue b/front/components/Field/SelectField.vue index ad40b26..744056b 100644 --- a/front/components/Field/SelectField.vue +++ b/front/components/Field/SelectField.vue @@ -1,13 +1,5 @@ \ No newline at end of file + diff --git a/front/components/Field/TextareaField.vue b/front/components/Field/TextareaField.vue new file mode 100644 index 0000000..2c16f6a --- /dev/null +++ b/front/components/Field/TextareaField.vue @@ -0,0 +1,25 @@ + + +