feat: 优化大文件下载 (#1183)

Refs https://github.com/1Panel-dev/1Panel/issues/1165
This commit is contained in:
zhengkunwang223 2023-05-30 00:00:56 +08:00 committed by GitHub
parent b5093e4d93
commit 800f9e2d38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 129 additions and 48 deletions

View file

@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@ -454,17 +455,98 @@ func (b *BaseApi) MoveFile(c *gin.Context) {
// @Router /files/download [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"下载文件 [name]","formatEN":"Download file [name]"}
func (b *BaseApi) Download(c *gin.Context) {
var req request.FileDownload
filePath := c.Query("path")
file, err := os.Open(filePath)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
}
info, _ := file.Stat()
c.Header("Content-Length", strconv.FormatInt(info.Size(), 10))
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
return
}
// @Tags File
// @Summary Chunk Download file
// @Description 分片下载下载文件
// @Accept json
// @Param request body request.FileDownload true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /files/chunkdownload [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"下载文件 [name]","formatEN":"Download file [name]"}
func (b *BaseApi) DownloadChunkFiles(c *gin.Context) {
var req request.FileChunkDownload
if err := c.ShouldBindJSON(&req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
filePath, err := fileService.FileDownload(req)
fileOp := files.NewFileOp()
if !fileOp.Stat(req.Path) {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrPathNotFound, nil)
return
}
filePath := req.Path
fstFile, err := fileOp.OpenFile(filePath)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
c.File(filePath)
info, err := fstFile.Stat()
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
if info.IsDir() {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrFileDownloadDir, err)
return
}
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", req.Name))
c.Writer.Header().Set("Content-Type", "application/octet-stream")
c.Writer.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
c.Writer.Header().Set("Accept-Ranges", "bytes")
if c.Request.Header.Get("Range") != "" {
rangeHeader := c.Request.Header.Get("Range")
rangeArr := strings.Split(rangeHeader, "=")[1]
rangeParts := strings.Split(rangeArr, "-")
startPos, _ := strconv.ParseInt(rangeParts[0], 10, 64)
var endPos int64
if rangeParts[1] == "" {
endPos = info.Size() - 1
} else {
endPos, _ = strconv.ParseInt(rangeParts[1], 10, 64)
}
c.Writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startPos, endPos, info.Size()))
c.Writer.WriteHeader(http.StatusPartialContent)
buffer := make([]byte, 1024*1024)
file, err := os.Open(filePath)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
defer file.Close()
file.Seek(startPos, 0)
reader := io.LimitReader(file, endPos-startPos+1)
_, err = io.CopyBuffer(c.Writer, reader, buffer)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
} else {
c.File(filePath)
}
}
// @Tags File

View file

@ -82,6 +82,11 @@ type FileDownload struct {
Compress bool `json:"compress" validate:"required"`
}
type FileChunkDownload struct {
Path string `json:"path" validate:"required"`
Name string `json:"name" validate:"required"`
}
type DirSizeReq struct {
Path string `json:"path" validate:"required"`
}

View file

@ -84,6 +84,7 @@ var (
ErrLinkPathNotFound = "ErrLinkPathNotFound"
ErrFileIsExit = "ErrFileIsExit"
ErrFileUpload = "ErrFileUpload"
ErrFileDownloadDir = "ErrFileDownloadDir"
)
// mysql

View file

@ -40,6 +40,7 @@ ErrMovePathFailed: "The target path cannot contain the original path!"
ErrLinkPathNotFound: "Target path does not exist!"
ErrFileIsExit: "File already exists!"
ErrFileUpload: "Failed to upload file {{.name}} {{.detail}}"
ErrFileDownloadDir: "Download folder not supported"
#website
ErrDomainIsExist: "Domain is already exist"

View file

@ -40,6 +40,7 @@ ErrMovePathFailed: "目标路径不能包含原路径!"
ErrLinkPathNotFound: "目标路径不存在!"
ErrFileIsExit: "文件已存在!"
ErrFileUpload: "{{ .name }} 上传文件失败 {{ .detail}}"
ErrFileDownloadDir: "不支持下载文件夹"
#website
ErrDomainIsExist: "域名已存在"

View file

@ -1,11 +1,10 @@
package router
import (
"github.com/gin-contrib/gzip"
"html/template"
"net/http"
"github.com/gin-contrib/gzip"
"github.com/1Panel-dev/1Panel/backend/global"
"github.com/1Panel-dev/1Panel/backend/i18n"
"github.com/1Panel-dev/1Panel/backend/middleware"
@ -18,23 +17,16 @@ import (
ginSwagger "github.com/swaggo/gin-swagger"
)
func setWebStatic(rootRouter *gin.Engine) {
func setWebStatic(rootRouter *gin.RouterGroup) {
rootRouter.StaticFS("/fav", http.FS(web.Favicon))
rootRouter.GET("/assets/*filepath", func(c *gin.Context) {
staticServer := http.FileServer(http.FS(web.Assets))
staticServer.ServeHTTP(c.Writer, c.Request)
})
rootRouter.GET("/", func(c *gin.Context) {
staticServer := http.FileServer(http.FS(web.IndexHtml))
staticServer.ServeHTTP(c.Writer, c.Request)
})
rootRouter.NoRoute(func(c *gin.Context) {
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.Write(web.IndexByte)
c.Writer.Header().Add("Accept", "text/html")
c.Writer.Flush()
})
}
func Routers() *gin.Engine {
@ -45,8 +37,14 @@ func Routers() *gin.Engine {
if global.CONF.System.IsDemo {
Router.Use(middleware.DemoHandle())
}
Router.Use(gzip.Gzip(gzip.DefaultCompression))
setWebStatic(Router)
Router.NoRoute(func(c *gin.Context) {
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.Write(web.IndexByte)
c.Writer.Header().Add("Accept", "text/html")
c.Writer.Flush()
})
Router.Use(i18n.GinI18nLocalize())
Router.SetFuncMap(template.FuncMap{
"Localize": ginI18n.GetMessage,
@ -61,6 +59,8 @@ func Routers() *gin.Engine {
PublicGroup.GET("/health", func(c *gin.Context) {
c.JSON(200, "ok")
})
PublicGroup.Use(gzip.Gzip(gzip.DefaultCompression))
setWebStatic(PublicGroup)
}
PrivateGroup := Router.Group("/api/v1")
PrivateGroup.Use(middleware.WhiteAllow())

View file

@ -32,8 +32,9 @@ func (f *FileRouter) InitFileRouter(Router *gin.RouterGroup) {
fileRouter.POST("/rename", baseApi.ChangeFileName)
fileRouter.POST("/wget", baseApi.WgetFile)
fileRouter.POST("/move", baseApi.MoveFile)
fileRouter.POST("/download", baseApi.Download)
fileRouter.GET("/download", baseApi.Download)
fileRouter.POST("/download/bypath", baseApi.DownloadFile)
fileRouter.POST("/chunkdownload", baseApi.DownloadChunkFiles)
fileRouter.POST("/size", baseApi.Size)
fileRouter.GET("/ws", baseApi.Ws)
fileRouter.GET("/keys", baseApi.Keys)

View file

@ -125,6 +125,11 @@ export namespace File {
url: string;
}
export interface FileChunkDownload {
name: string;
path: string;
}
export interface DirSizeReq {
path: string;
}

View file

@ -67,9 +67,6 @@
<el-button plain @click="openCompress(selects)" :disabled="selects.length === 0">
{{ $t('file.compress') }}
</el-button>
<el-button plain @click="openDownload" :disabled="selects.length === 0">
{{ $t('file.download') }}
</el-button>
<el-button plain @click="batchDelFiles" :disabled="selects.length === 0">
{{ $t('commons.button.delete') }}
</el-button>
@ -156,7 +153,7 @@
show-overflow-tooltip
></el-table-column>
<fu-table-operations
:ellipsis="2"
:ellipsis="3"
:buttons="buttons"
:label="$t('commons.table.operate')"
min-width="200"
@ -185,7 +182,7 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from '@vue/runtime-core';
import { GetFilesList, DeleteFile, GetFileContent, ComputeDirSize, DownloadFile } from '@/api/modules/files';
import { GetFilesList, DeleteFile, GetFileContent, ComputeDirSize } from '@/api/modules/files';
import { computeSize, dateFormat, getIcon, getRandomStr } from '@/utils/util';
import { File } from '@/api/interface/file';
import { useDeleteData } from '@/hooks/use-delete-data';
@ -210,6 +207,8 @@ import { MsgSuccess, MsgWarning } from '@/utils/message';
import { ElMessageBox } from 'element-plus';
import { useSearchable } from './hooks/searchable';
import { ResultData } from '@/api/interface';
// import streamSaver from 'streamsaver';
// import axios from 'axios';
interface FilePaths {
url: string;
@ -245,8 +244,8 @@ const fileUpload = reactive({ path: '' });
const fileRename = reactive({ path: '', oldName: '' });
const fileWget = reactive({ path: '' });
const fileMove = reactive({ oldPaths: [''], type: '', path: '' });
const fileDownload = reactive({ paths: [''], name: '' });
const processPage = reactive({ open: false });
// const fileDownload = reactive({ path: '', name: '' });
const createRef = ref();
const roleRef = ref();
@ -573,32 +572,9 @@ const openPaste = () => {
moveRef.value.acceptParams(fileMove);
};
const openDownload = () => {
const paths = [];
for (const s of selects.value) {
paths.push(s['path']);
}
fileDownload.paths = paths;
if (selects.value.length > 1 || selects.value[0].isDir) {
fileDownload.name = selects.value.length > 1 ? getRandomStr(6) : selects.value[0].name;
downloadRef.value.acceptParams(fileDownload);
} else {
loading.value = true;
fileDownload.name = selects.value[0].name;
DownloadFile(fileDownload as File.FileDownload)
.then((res) => {
const downloadUrl = window.URL.createObjectURL(new Blob([res], { type: 'application/octet-stream' }));
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
a.download = fileDownload.name;
const event = new MouseEvent('click');
a.dispatchEvent(event);
})
.finally(() => {
loading.value = false;
});
}
const openDownload = (file: File.File) => {
let url = `${import.meta.env.VITE_API_URL as string}/files/download?`;
window.open(url + 'path=' + file.path, '_blank');
};
const openDetail = (row: File.File) => {
@ -610,6 +586,15 @@ const buttons = [
label: i18n.global.t('file.open'),
click: open,
},
{
label: i18n.global.t('file.download'),
click: (row: File.File) => {
openDownload(row);
},
disabled: (row: File.File) => {
return row.isDir;
},
},
{
label: i18n.global.t('file.deCompress'),
click: openDeCompress,