kkfile安全预览minio的文件

上一个项目使用了minio作为文件存储服务,搭配上kkfileview作为文件预览的组件,所以这篇博客作为轮子写出来发表,也算是为了自己以后方便。

​ 做的功能主要包括minio上传下载文件、bucket`管理、以及文件暴露地址预览和安全文件预览。

1.配置

​ 在application中引入二者相关的配置,其中miniofileDownloadPrefixkkfileviewpreviewTokenName是为了完成自己的需求添加的。

minio:
  endpoint: http://10.247.82.182:23000 #Minio服务所在地址
  bucketName: dyyj #存储桶名称
  accessKey: pansoft #访问的key
  secretKey:  #访问的秘钥
  fileDownloadPrefix: http://0.0.0.0:80/prod-api

# 文件预览软件访问路径
kkfileview:
  # 访问地址
  server: http://0.0.0.0:9091/
  # 单文件预览-路径拼接
  single: onlinePreview
  # 多图片预览-路径拼接
  images: picturesPreview
  previewTokenName: file:preview_token- # 预览时生成的key存放在redis中
  previewExpiredTime: 300 # 生成的令牌有效期

​ 对应的有两个Bean配置

@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {

    private String endpoint;
    private String accessKey;
    private String secretKey;
    private String bucketName;
    private String fileDownloadPrefix;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}
@Component
@ConfigurationProperties(prefix = "kkfileview")
public class KkFileViewProperties {

    /**
     * 文件预览服务地址,例如:https://192.168.136.238:8012/kkfileview/
     */
    private String server;

    /**
     * 单文件预览路径拼接,例如:onlinePreview
     */
    private String single;

    /**
     * 多图片预览路径拼接,例如:picturesPreview
     */
    private String images;

    /**
     * Redis 中预览令牌的键名前缀
     */
    private String previewTokenName;

    /**
     * 预览令牌有效期(秒)
     */
    private Integer previewExpiredTime;

    // ================== Getter / Setter ==================

    public String getServer() {
        return server;
    }

    public void setServer(String server) {
        this.server = server;
    }

    public String getSingle() {
        return single;
    }

    public void setSingle(String single) {
        this.single = single;
    }

    public String getImages() {
        return images;
    }

    public void setImages(String images) {
        this.images = images;
    }

    public String getPreviewTokenName() {
        return previewTokenName;
    }

    public void setPreviewTokenName(String previewTokenName) {
        this.previewTokenName = previewTokenName;
    }

    public Integer getPreviewExpiredTime() {
        return previewExpiredTime;
    }

    public void setPreviewExpiredTime(Integer previewExpiredTime) {
        this.previewExpiredTime = previewExpiredTime;
    }

    // ================== 可选:便捷方法 ==================

    /**
     * 获取完整的单文件预览 URL(例如  https://192.168.136.238:8012/kkfileview/onlinePreview )
     */
    public String getSinglePreviewUrl() {
        return appendPath(server, single);
    }

    /**
     * 获取完整的多图片预览 URL(例如  https://192.168.136.238:8012/kkfileview/picturesPreview )
     */
    public String getImagesPreviewUrl() {
        return appendPath(server, images);
    }

    private String appendPath(String base, String path) {
        if (base == null) return path;
        if (!base.endsWith("/")) base = base + "/";
        return base + (path != null ? path : "");
    }
}

2.工具类

​ 工具类有的是从网上找的,有的是我自己写的,基本上比较全了;第一个工具类是用于进行minio的文件操作,第二个工具类用于实现隐藏minio地址实现文件单次有效的预览和下载。

​ 这里说下实现安全下载或者预览的思路(不过我们公司比较拉国企内网不太在乎这个):

​ 我们知道kkfile预览的urlhttps://file.kkview.cn/onlinePreview?url=base64之后的文件地址,说白了就是后边的东西要被读取成文件流,而minio的文件我们访问是http://地址:端口/bucket名称/文件名称,为了保证这个真实的地址不被暴露我选择是通过自己的后端应用去下载文件,也就是把这个真实url作为参数传递给一个后端的下载接口,变相的获得文件流进而预览。

​ 为了保证只被下载一次,所以每次下载之前都让前端调用接口获得一个token,进入下载接口之后验证这个token有效期之后立即删除,这样就能保证这个下载地址仅一次生效,我是将下载接口设计成/download?token=token&filename=这样的格式,细节就不讲了。

@Component
@Slf4j
public class MinioUtil {
    @Autowired
    private MinioConfig prop;

    @Resource
    private MinioClient minioClient;

    /**
     * 查看存储bucket是否存在
     * @return boolean
     */
    public Boolean bucketExists(String bucketName) {
        Boolean found;
        try {
            found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return found;
    }

    /**
     * 创建存储bucket
     * @return Boolean
     */
    public Boolean makeBucket(String bucketName) {
        try {
            minioClient.makeBucket(MakeBucketArgs.builder()
                    .bucket(bucketName)
                    .build());
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
    /**
     * 删除存储bucket
     * @return Boolean
     */
    public Boolean removeBucket(String bucketName) {
        try {
            minioClient.removeBucket(RemoveBucketArgs.builder()
                    .bucket(bucketName)
                    .build());
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
    /**
     * 获取全部bucket
     */
    public List<Bucket> getAllBuckets() {
        try {
            List<Bucket> buckets = minioClient.listBuckets();
            return buckets;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }



    /**
     * 文件上传
     *
     * @param file 文件
     * @return Boolean
     */
    public String upload(MultipartFile file, String foldName) {
        String originalFilename = file.getOriginalFilename();
        if (StringUtils.isBlank(originalFilename)){
            throw new RuntimeException();
        }
        String fileName = UUID.fastUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
        String objectName = "";
        if (StringUtils.isNotEmpty(foldName)) {
            objectName = foldName +"/"+ DateUtil.date().toString("yyyy-MM-dd") + "/" + fileName;
        } else {
            objectName = DateUtil.date().toString("yyyy-MM-dd") + "/" + fileName;
        }
        try {
            PutObjectArgs objectArgs = PutObjectArgs.builder().bucket(prop.getBucketName()).object(objectName)
                    .stream(file.getInputStream(), file.getSize(), -1).contentType(file.getContentType()).build();
            //文件名称相同会覆盖
            minioClient.putObject(objectArgs);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
        return objectName;
    }

    /**
     * 预览图片
     * @param fileName
     * @return
     */
    public String preview(String fileName){
        // 查看文件地址
        GetPresignedObjectUrlArgs build = new GetPresignedObjectUrlArgs().builder().bucket(prop.getBucketName()).object(fileName).method(io.minio.http.Method.GET).build();
        try {
            String url = minioClient.getPresignedObjectUrl(build);
            return url;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 文件下载
     * @param fileName 文件名称
     * @param res response
     * @return Boolean
     */
    public void download(String filename, HttpServletResponse response) throws ServiceException {
        try {
            InputStream inputStream = minioClient.getObject(GetObjectArgs.builder().bucket(prop.getBucketName()).object(filename).build());


            // 设置响应头信息,告诉前端浏览器下载文件
            response.setHeader("Content-Disposition", "inline; filename=" + URLEncoder.encode(filename, "UTF-8"));
            response.setContentType("application/octet-stream");

            // 获取输出流进行写入数据
            OutputStream outputStream = response.getOutputStream();
            // 将输入流复制到输出流
            byte[] buffer = new byte[4096];
            int bytesRead = -1;

            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }
            // 关闭流资源
            inputStream.close();
            outputStream.close();
        } catch (Exception e) {
            log.error("文件下载失败:" + e.getMessage());
            throw new ServiceException("文件下载失败");
        }
    }


    /**
     * 查看文件对象
     * @return 存储bucket内文件对象信息
     */
    public List<Item> listObjects() {
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder().bucket(prop.getBucketName()).build());
        List<Item> items = new ArrayList<>();
        try {
            for (Result<Item> result : results) {
                items.add(result.get());
            }
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
        return items;
    }

    /**
     * 删除
     * @param fileName
     * @return
     * @throws Exception
     */
    public boolean remove(String fileName){
        try {
            minioClient.removeObject( RemoveObjectArgs.builder().bucket(prop.getBucketName()).object(fileName).build());
        }catch (Exception e){
            return false;
        }
        return true;
    }
    public List<String> urlList() throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder().bucket(prop.getBucketName()).recursive(true).build()
        );
        List<String> urlList = new ArrayList<>();
        for (Result<Item> result : results) {
            Item item = result.get();
            urlList.add(item.objectName());  // 这里就是完整的路径,例如 folder1/file1.txt
        }
        return urlList;
    }

}
/**
 * @author qihaotian
 * @description: 用于提供文件预览时的服务
 * @date 2025/11/3 16:11
 */
@Component
public class FilePreviewUtil {
    @Resource
    private KkFileViewProperties kkfile;
    @Resource
    private RedisCache redisService;

    /**
     * 发放令牌 - 生成一个临时、短效的访问令牌
     * @param filename filename MinIO 中存储的实际文件名
     * @return 生成的临时 token 字符串
     */
    public String generateToken(String filename) {
        // 1. 生成一个唯一的 UUID 作为 token
        String token = UUID.randomUUID().toString().replaceAll("-", "");
        // 2. 存储 token 到 Redis,value 存入文件名,并设置过期时间
        // 存储格式:key = file:preview_token:UUID, value = filename
        redisService.setCacheObject(
                kkfile.getPreviewTokenName() + token,
                filename,
                kkfile.getPreviewExpiredTime(),
                TimeUnit.SECONDS
        );
        // 3. 返回 token
        return token;
    }

    /**
     * 鉴权令牌 验证 token 是否合法、是否过期,并获取文件名
     * @param token token URL 路径中传递的临时 token
     * @return 对应的 MinIO 文件名 (如果验证通过)
     * @throws ServiceException 如果 token 无效或已过期
     */
    public String validateToken(String token) throws ServiceException {
        if (StringUtils.isBlank(token)) {
            throw new ServiceException("文件访问令牌不能为空");
        }
        // 1. 从 Redis 中获取 token 对应的文件名
        String filename = redisService.getCacheObject(kkfile.getPreviewTokenName() + token);
        if (StringUtils.isBlank(filename)) {
            // token 不存在(未生成过)或已过期/被手动清除
            throw new ServiceException("文件访问令牌无效或已过期");
        }
        // 2. 【核心安全机制】验证通过后,立即删除 token,实现单次访问 (One-Time-Token)
        // 这样 KKFile 请求完文件流后,该 token 立即失效,防止被二次使用或恶意分享。
        redisService.deleteObject(kkfile.getPreviewTokenName() + token);
        // 3. 验证成功,返回文件名
        return filename;
    }
}

3.接口和服务

​ 接口按需取得就行,之前还想写个批量操作的没有写完好像。

@Slf4j
@RestController
@RequestMapping("/minio")
public class FileController {


    @Autowired
    private MinioUtil minioUtil;
    @Autowired
    private MinioConfig prop;
    @Resource
    private FilePreviewUtil filePreviewUtil;
    @Resource
    private KkFileViewProperties kkfile;
    @Resource
    private final KkFileViewProperties kkFileViewProperties;
    public FileController(KkFileViewProperties kkFileViewProperties) {
        this.kkFileViewProperties = kkFileViewProperties;
    }

    @ApiOperation(value = "查看存储bucket是否存在")
    @GetMapping("/bucketExists")
    public AjaxResult bucketExists(@RequestParam("bucketName") String bucketName) {
        return AjaxResult.success().put("bucketName",minioUtil.bucketExists(bucketName));
    }

    @ApiOperation(value = "创建存储bucket")
    @GetMapping("/makeBucket")
    public AjaxResult makeBucket(String bucketName) {
        return AjaxResult.success().put("bucketName",minioUtil.makeBucket(bucketName));
    }

    @ApiOperation(value = "删除存储bucket")
    @GetMapping("/removeBucket")
    public AjaxResult removeBucket(String bucketName) {
        return AjaxResult.success().put("bucketName",minioUtil.removeBucket(bucketName));
    }

    @ApiOperation(value = "获取全部bucket")
    @GetMapping("/getAllBuckets")
    public AjaxResult getAllBuckets() {
        List<Bucket> allBuckets = minioUtil.getAllBuckets();
        return AjaxResult.success().put("allBuckets",allBuckets);
    }

    @ApiOperation(value = "文件上传返回url和文件名")
    @PostMapping("/upload")
    public AjaxResult upload(@RequestParam("file") MultipartFile file, @RequestParam(required = false) String foldName) {
        String originalFilename = file.getOriginalFilename();
        String objectName = minioUtil.upload(file, foldName);
        if (null != objectName) {
            return AjaxResult.success().put("url",(prop.getEndpoint() + "/" + prop.getBucketName() + "/" + objectName)).put("originalName",originalFilename);
        }
        return AjaxResult.error("上传文件失败!");
    }

//    @ApiOperation(value = "图片/视频预览")
//    @GetMapping("/preview")
//    public AjaxResult preview(@RequestParam("fileName") String fileName) {
//        return AjaxResult.success().put("fileName",minioUtil.preview(fileName));
//    }

//    @ApiOperation(value = "文件下载")
//    @GetMapping("/download")
//    public AjaxResult download(@RequestParam("fileName") String fileName, HttpServletResponse res) {
//        minioUtil.download(fileName,res);
//        return AjaxResult.success();
//    }

    @ApiOperation(value = "删除文件", notes = "根据url地址删除文件")
    @PostMapping("/delete")
    public AjaxResult remove(String url) {
        String objName = url.substring(url.lastIndexOf(prop.getBucketName()+"/") + prop.getBucketName().length()+1);
        minioUtil.remove(objName);
        return AjaxResult.success().put("objName",objName);
    }
    @ApiOperation(value = "文件列表", notes = "获取文件列表")
    @GetMapping("/list-file")
    public AjaxResult listFile() throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
        List<String> list = minioUtil.urlList(); // 循环调用remove就行
        return AjaxResult.success();
    }

    @ApiOperation("获取预览文件的key")
    @GetMapping("/get-preview-key")
    public AjaxResult getPreviewKey(@RequestParam("filename") String filename) {
        return AjaxResult.success().put("previewKey",filePreviewUtil.generateToken(filename));
    }
    @ApiOperation("文件下载")
    @GetMapping("/download")
    public void getDownload(@RequestParam("token") String token,
                            @RequestParam("filename") String filename,
                            HttpServletResponse response) {
        try {
            String actualFilename = filePreviewUtil.validateToken(token);
            minioUtil.download(actualFilename, response);
        } catch (ServiceException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized 或 403 Forbidden
        }
    }
    @ApiOperation("kkfile文件预览(不暴露minio地址,只能预览一次)")
    @GetMapping("/preview")
    public String getPreviewUrl(@RequestParam("token") String token, @RequestParam("filename") String filename) {
        // 参数非空校验
        if (StringUtils.isEmpty(token)) {
            throw new ServiceException("token不能为空");
        }
        if (StringUtils.isEmpty(filename)) {
            throw new ServiceException("filename不能为空");
        }
        // 从后往前截取到第一个 "." 作为文件拓展名
        String filenameExtension = filename.substring(filename.lastIndexOf(".") + 1);
        // 示例 http://192.168.136.238:9876/ice/test/2025-11-13/1909ab9e-6863-427f-a742-df49d12e2905.pdf&fullfilename=2025-11-13.pdf
        String downloadPath = prop.getFileDownloadPrefix() + "filename=" + filename + "&token=" + token + "&fullfilename="+ LocalDate.now() + "." + filenameExtension;
        String kkfilePreviewUrl = kkfile.getServer()  + kkfile.getSingle() + "?url=";
        return  kkfilePreviewUrl + Base64.encode(downloadPath) + "&officePreviewType=" + filenameExtension;
    }

    @ApiOperation("文件预览(允许暴露地址)")
    @GetMapping("/preview-expose")
    public String getPreviewUrlExpose(@RequestParam("filename") String filename) throws UnsupportedEncodingException {
        // 示例 http://192.168.136.238:9876/ice/test/2025-11-13/1909ab9e-6863-427f-a742-df49d12e2905.pdf
        String downloadPath = prop.getEndpoint() + "/" + prop.getBucketName() + filename;
        String kkfilePreviewUrl = kkfile.getServer()  + kkfile.getSingle() + "?url=";
        // 转化下载地址成kkfile能够识别的
        return  kkfilePreviewUrl + Base64.encodeUrlSafe(downloadPath);
    }
}
posted @ 2026-06-03 21:10  Purearc  阅读(28)  评论(0)    收藏  举报