文章目录
一、简介
1、概述
文件上传是Web项目的一个基本功能,一般是通过上传文件的后缀名进行格式校验,但是由于文件的后缀是可以手动更改的,黑客可以通过修改后缀名入侵文件服务器,因此后缀名校验不是一种严格有效的文件校验方式。如果想要对上传文件进行严格的格式校验,则需要通过文件头进行校验,即魔数,文件头是位于文件开头的一段承担一定任务的数据,一般都在开头的部分,其作用就是为了描述一个文件的一些重要的属性,其可以作为是一类特定文件的标识。
2、环境与技术介绍
SpringBoot2.5.6,AOP思想
使用切面编程,在文件上传之前,通过自定义注解首先进行自定义文件类型判断,若判断不通过,则通过全全局自定义异常返回,通过所有检查后才进行文件的上传,同时通过ConditionalOnProperty
注解可以在application.yml
中进行注解文件的打开或关闭,即校验文件功能的开启与关闭。
3、简单的文件上传
@Value("${file.staticPath}")
private String staticPath;
@Value("${file.uploadFolder}")
private String uploadFolder;
/**
* 上传文件,比较通用的方法,这里我写在这里可以进行参考修改
* 其他方法
*/
public String uploadFile(MultipartFile multipartFile, String dir) {
try {
//上传的文件:aaa.jpg
String realFileName = multipartFile.getOriginalFilename();
//2:藏图文件名的后级
String imgSuffix = realFileName.substring(realFileName.lastIndexOf("."));
//3:生成的唯一的文件名:能不能用中文名:不能因为统一用英文命名。
String newFileName = UUID.randomUUID().toString() + imgSuffix;
//4:日期目录
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd");
String datePath = dateFormat.format(new Date());
//5:服务路径
String serverName = uploadFolder;
//6:指定文件上传以后的目录
File targetPath = new File(serverName + dir, datePath);
if (!targetPath.exists()) {
targetPath.mkdirs();
}
//6:指定文件上传以后的服务器的完整的文件名
File targetFileName = new File(targetPath, newFileName);
//7:文件上传到指定的目录
multipartFile.transferTo(targetFileName);
// 返回的自由选择,可以选择Map进行返回
String fileName = dir + File.separator + datePath + File.separator + newFileName;
return staticPath + File.separator + fileName;
} catch (IOException e) {
e.printStackTrace();
return "fail";
}
}
yml中进行配置
file:
staticPatternPath: /upload/**
uploadFolder: /www/upload/
staticPath: http://www.shawn22.xyz:8080
二、文件校验与上传实战
1、 前提准备
2、 文件枚举类
包含了每种文件的后缀名与头部魔数
/**
* 文件类型
* 文件魔数
* @author Shawn
* @date 2021/11/23
*/
@Getter
public enum FileType {
/**
* JPEG (jpg)
*/
JPEG("JPEG", "FFD8FF"),
JPG("JPG", "FFD8FF"),
/**
* PNG
*/
PNG("PNG", "89504E47"),
/**
* GIF
*/
GIF("GIF", "47494638"),
/**
* TIFF (tif)
*/
TIFF("TIF", "49492A00"),
/**
* Windows bitmap (bmp)
*/
BMP("BMP", "424D"),
/**
* 16色位图(bmp)
*/
BMP_16("BMP", "424D228C010000000000"),
/**
* 24位位图(bmp)
*/
BMP_24("BMP", "424D8240090000000000"),
/**
* 256色位图(bmp)
*/
BMP_256("BMP", "424D8E1B030000000000"),
/**
* CAD (dwg)
*/
DWG("DWG", "41433130"),
/**
* Adobe photoshop (psd)
*/
PSD("PSD", "38425053"),
/**
* Rich Text Format (rtf)
*/
RTF("RTF", "7B5C727466"),
/**
* XML
*/
XML("XML", "3C3F786D6C"),
/**
* HTML (html)
*/
HTML("HTML", "68746D6C3E"),
/**
* Email [thorough only] (eml)
*/
EML("EML", "44656C69766572792D646174653A"),
/**
* Outlook Express (dbx)
*/
DBX("DBX", "CFAD12FEC5FD746F "),
/**
* Outlook (pst)
*/
PST("", "2142444E"),
/**
* doc;xls;dot;ppt;xla;ppa;pps;pot;msi;sdw;db
*/
OLE2("OLE2", "0xD0CF11E0A1B11AE1"),
/**
* Microsoft Word/Excel 注意:word 和 excel的文件头一样
*/
XLS("XLS", "D0CF11E0"),
/**
* Microsoft Word/Excel 注意:word 和 excel的文件头一样
*/
DOC("DOC", "D0CF11E0"),
/**
* Microsoft Word/Excel 2007以上版本文件 注意:word 和 excel的文件头一样
*/
DOCX("DOCX", "504B0304"),
/**
* Microsoft Word/Excel 2007以上版本文件 注意:word 和 excel的文件头一样 504B030414000600080000002100
*/
XLSX("XLSX", "504B0304"),
/**
* Microsoft Access (mdb)
*/
MDB("MDB", "5374616E64617264204A"),
/**
* Adobe Acrobat (pdf) 255044462D312E
*/
PDF("PDF", "25504446"),
/**
* Windows Password (pwl)
*/
PWL("PWL", "E3828596"),
/**
* WAVE (wav)
*/
WAV("WAV", "57415645"),
/**
* AVI
*/
AVI("AVI", "41564920"),
/**
* Real Audio (ram)
*/
RAM("RAM", "2E7261FD"),
/**
* Real Media (rm) rmvb/rm相同
*/
RM("RM", "2E524D46"),
/**
* Real Media (rm) rmvb/rm相同
*/
RMVB("RMVB", "2E524D46000000120001"),
/**
* MPEG (mpg)
*/
MPG("MPG", "000001BA"),
/**
* Quicktime (mov)
*/
MOV("MOV", "6D6F6F76"),
/**
* MIDI (mid)
*/
MID("MID", "4D546864"),
/**
* MP4
*/
MP4("MP4", "00000020667479706D70"),
/**
* MP3
*/
MP3("MP3", "49443303000000002176"),
/**
* FLV
*/
FLV("FLV", "464C5601050000000900"),
/**
* torrent
*/
TORRENT("TORRENT", "6431303A637265617465"),
/**
* JSP Archive
*/
JSP("JSP", "3C2540207061676520"),
/**
* JAVA Archive
*/
JAVA("JAVA", "7061636B61676520"),
/**
* CLASS Archive
*/
CLASS("CLASS", "CAFEBABE0000002E00"),
/**
* JAR Archive
*/
JAR("JAR", "504B03040A000000"),
/**
* MF Archive
*/
MF("MF", "4D616E69666573742D56"),
/**
* EXE Archive
*/
EXE("EXE", "4D5A9000030000000400"),
/**
* ELF Executable
*/
ELF("ELF", "7F454C4601010100"),
/**
* Lotus 123 v1
*/
WK1("WK1", "2000604060"),
/**
* Lotus 123 v3
*/
WK3("WK3", "00001A0000100400"),
/**
* Lotus 123 v5
*/
WK4("WK4", "00001A0002100400"),
/**
* Lotus WordPro v9
*/
LWP("LWP", "576F726450726F"),
/**
* Sage(sly.or.srt.or.slt;sly;srt;slt)
*/
SLY("SLY", "53520100");
/**
* 后缀 大写字母
*/
private final String suffix;
/**
* 魔数
*/
private final String magicNumber;
FileType(String suffix, String magicNumber) {
this.suffix = suffix;
this.magicNumber = magicNumber;
}
@NonNull
public static FileType getBySuffix(String suffix) throws FileUploadException {
for (FileType fileType : FileType.values()) {
if (fileType.getSuffix().equals(suffix.toUpperCase())) {
return fileType;
}
}
throw new FileUploadException("不支持的文件后缀 : " + suffix);
}
}
3、 自定义文件校验注解
/**
* 文件检查
*
* @author Shawn
* @date 2021/11/23
*/
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
public @interface FileCheck {
/**
* 校验不通过提示信息
*
* @return
*/
String message() default "不支持的文件格式";
/**
* 校验方式
*/
CheckType type() default CheckType.SUFFIX;
/**
* 支持的文件后缀
*
* @return
*/
String[] supportedSuffixes() default {};
/**
* 支持的文件类型
*
* @return
*/
FileType[] supportedFileTypes() default {};
enum CheckType {
/**
* 仅校验后缀
*/
SUFFIX,
/**
* 校验文件头(魔数)
*/
MAGIC_NUMBER,
/**
* 同时校验后缀和文件头
*/
SUFFIX_MAGIC_NUMBER
}
}
4、 文件校验切面
/**
* @author Shawn
* @date 2021年11月23日9:32
* prefix为配置文件中的前缀,
* name为配置的名字
* havingValue是与配置的值对比值,当两个值相同返回true,配置类生效
* 需要在yml中进行配置:前缀+名字,值为true,表示该配置文件生效
**/
@Aspect
@Slf4j
@Component
@ConditionalOnProperty(prefix = "file-check", name = "enabled", havingValue = "true")
public class FileCheckAspect {
/**
* 目标方法:被@FileCheck注解的方法即为目标方法
* 其中@annotation中的值,需要和target方法中参数名称相同(必须相同,但是名称任意)
*
* @param joinPoint 连接点
* @param annotation 文件检查
*/
@Before("@annotation(annotation)")
public void before(JoinPoint joinPoint, FileCheck annotation) throws FileUploadException {
final String[] suffixes = annotation.supportedSuffixes();
final FileCheck.CheckType type = annotation.type();
final FileType[] fileTypes = annotation.supportedFileTypes();
final String message = annotation.message();
// 支持的文件后缀和文件类型有一个为空则返回
if (ArrayUtils.isEmpty(suffixes) && ArrayUtils.isEmpty(fileTypes)) {
return;
}
Object[] args = joinPoint.getArgs();
//文件后缀转成set集合
Set<String> suffixSet = new HashSet<>(Arrays.asList(suffixes));
for (FileType fileType : fileTypes) {
suffixSet.add(fileType.getSuffix());
}
//文件类型转成set集合
Set<FileType> fileTypeSet = new HashSet<>(Arrays.asList(fileTypes));
for (String suffix : suffixes) {
fileTypeSet.add(FileType.getBySuffix(suffix));
}
//对参数是文件的进行校验
for (Object arg : args) {
if (arg instanceof MultipartFile) {
doCheck((MultipartFile) arg, type, suffixSet, fileTypeSet, message);
} else if (arg instanceof MultipartFile[]) {
for (MultipartFile file : (MultipartFile[]) arg) {
doCheck(file, type, suffixSet, fileTypeSet, message);
}
}
}
}
/**
* 根据指定的检查类型对文件进行校验
*/
private void doCheck(MultipartFile file, FileCheck.CheckType type, Set<String> suffixSet, Set<FileType> fileTypeSet, String message) throws FileUploadException {
if (type == FileCheck.CheckType.SUFFIX) {
doCheckSuffix(file, suffixSet, message);
} else if (type == FileCheck.CheckType.MAGIC_NUMBER) {
doCheckMagicNumber(file, fileTypeSet, message);
} else {
doCheckSuffix(file, suffixSet, message);
doCheckMagicNumber(file, fileTypeSet, message);
}
}
/**
* 验证文件魔数
*/
private void doCheckMagicNumber(MultipartFile file, Set<FileType> fileTypeSet, String message) throws FileUploadException {
String magicNumber = readMagicNumber(file);
String fileName = file.getOriginalFilename();
String fileSuffix = fileName.substring(fileName.lastIndexOf(".") + 1).toUpperCase();
for (FileType fileType : fileTypeSet) {
if (magicNumber.startsWith(fileType.getMagicNumber()) && fileType.getSuffix().toUpperCase().equalsIgnoreCase(fileSuffix)) {
return;
}
}
log.error("文件头格式错误:{}", magicNumber);
throw new FileUploadException(message);
}
/**
* 验证文件后缀
*/
private void doCheckSuffix(MultipartFile file, Set<String> suffixSet, String message) throws FileUploadException {
String fileName = file.getOriginalFilename();
String fileSuffix = fileName.substring(fileName.lastIndexOf(".") + 1).toUpperCase();
for (String suffix : suffixSet) {
if (suffix.toUpperCase().equalsIgnoreCase(fileSuffix)) {
return;
}
}
log.error("文件后缀格式错误:{}", message);
throw new FileUploadException(message);
}
/**
* 读取文件,获取文件头
*/
private String readMagicNumber(MultipartFile file) throws FileUploadException {
try (InputStream is = file.getInputStream()) {
byte[] fileHeader = new byte[4];
is.read(fileHeader, 0, 4);
return byteArray2Hex(fileHeader);
} catch (IOException e) {
log.error("文件读取错误:{0}", e);
throw new FileUploadException("读取文件失败!");
}
}
/**
* 字节数组转十六进制
*/
private String byteArray2Hex(byte[] data) {
StringBuilder stringBuilder = new StringBuilder();
if (ArrayUtils.isEmpty(data)) {
return null;
}
for (byte datum : data) {
int v = datum & 0xFF;
String hv = Integer.toHexString(v).toUpperCase();
if (hv.length() < 2) {
stringBuilder.append(0);
}
stringBuilder.append(hv);
}
return stringBuilder.toString();
}
}
5、 文件上传工具类
/**
* 文件上传工具类
* @author Shawn
* @date 2021年11月22日19:45
**/
public class FileUtils {
private static final Logger logger = LoggerFactory.getLogger(FileUtils.class);
/**
* 文件上传
*
* @param file 文件
* @return {@link String}
* @throws Exception 异常
*/
public static String fileUpload(Integer type, Integer userId,MultipartFile file) throws FileUploadException {
// 获取文件名,带后缀
String originalFilename = file.getOriginalFilename();
// 获取文件的后缀格式
String fileSuffix = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
String filePrefix = String.valueOf(System.currentTimeMillis())
.concat(String.valueOf(type))
.concat(String.valueOf(userId));
String newFileName = filePrefix.concat(".").concat(fileSuffix);
String dirPath;
// 判断上传类型
if(type == 0 ){
dirPath = FileLocationEnum.LocalVideoLocation.getLocation();
}else{
dirPath = FileLocationEnum.LocalPicLocation.getLocation();
}
String path = dirPath + newFileName;
File destFile = new File(dirPath + newFileName);
if (!destFile.getParentFile().exists()) {
destFile.getParentFile().mkdirs();
}
try {
file.transferTo(destFile);
logger.info("单次上传文件成功");
// 将相对路径返回给前端
return path;
} catch (IOException e) {
logger.error("upload pic error");
throw new FileUploadException("上传文件错误");
}
}
/**
* 文件上传的图片
*
* @param type 类型,图片为1,视频为0
* @param userId 用户id
* @param files 文件
* @return {@link List<String>}
*/
public static List<String> fileUploadWithPics(int type, Integer userId, MultipartFile[] files) throws FileUploadException {
List<String> picList = new ArrayList<>();
for (MultipartFile file:files) {
picList.add(fileUpload(type,userId,file));
}
logger.info("多图片文件上传成功");
return picList;
}
}
6、 控制类
这里提供了一个视频上传接口和多图片上传接口
/**
* @author Shawn
* @date 2021年11月22日21:09
**/
@RestController
@RequestMapping("/file")
public class FileUploadController {
/**
* 文件上传的图片
* 同时校验后缀和文件头
* @param userId 用户id
* @param file 文件
* @return {@link ResultVO<?>}
* @throws Exception 异常
*/
@PostMapping("/fileuploadwithpics")
@FileCheck(message = "不支持的图片格式",
supportedSuffixes = {"png", "jpg", "jpeg"},
type = FileCheck.CheckType.SUFFIX_MAGIC_NUMBER,
supportedFileTypes = {FileType.PNG, FileType.JPG, FileType.JPEG})
public ResultVO<?> fileUploadWithPics(Integer userId, @RequestParam("pics") MultipartFile[] MultipartFile) throws Exception {
if(userId==null){
return new ResultVO<>(400,"缺少userId参数");
}
// 1表示图片,0 表示视频
List<String> result = FileUtils.fileUploadWithPics(1, userId, MultipartFile);
Map<String, List<String>> map = new HashMap<>(4);
map.put("picUrl",result);
return new ResultVO<>(map);
}
/**
* 文件上传视频
* 仅校验后缀
* @param userId 用户id
* @param file 文件
* @return {@link ResultVO<?>}
* @throws Exception 异常
*/
@PostMapping("/fileuploadwithvideo")
@FileCheck(message = "不支持的视频格式",
type = FileCheck.CheckType.SUFFIX,
supportedSuffixes = {"mp4","gif"})
public ResultVO<?> fileUploadWithVideo(Integer userId, @RequestParam("video") MultipartFile file) throws Exception {
if(userId==null){
return new ResultVO<>(400,"缺少userId参数");
}
String s = FileUtils.fileUpload(0, userId, file);
Map<String, String> map = new HashMap<>(4);
map.put("videoUrl",s);
return new ResultVO<>(map);
}
}
7、 配置文件
在application.yml
进行配置
spring:
servlet:
multipart:
enabled: true
# 单个文件大小,m默认1M
max-file-size: 10MB
# 总上传文件大小,默认10M
max-request-size: 30MB
# 文件多少时写入磁盘,默认为0,有文件就写入
# file-size-threshold: 10MB
8、 文件的前端显示
一种是Nginx进行映射,这种方式比较常见;另一种是SpringBoot自带的映射穿透,需要在application配置好映射关系,或者在java里配置好映射关系。
若视频放在D:\social\
文件夹下,最终资源访问路径http://ip:port/social/xxxx
Yml配置文件方式
spring:
mvc:
static-path-pattern: /social/**
web:
resources:
static-locations: file:D:\social\
javaBean配置方式
/**
#application.yml中的配置
file:
staticPatternPath: /social/**
uploadFolder: file:D:\social\
*/
//这个注解必须加,将该bean交给Spring管理,否则无法解析@Value
@Component
public class WebMvcConfig implements WebMvcConfigurer {
@Value("${file.staticPatternPath}")
private String staticPatternPath;
@Value("${file.uploadFolder}")
private String uploadFolder;
// 这个方法是springboot中springMvc让程序开发者去配置文件上传的额外的静态资源服务的配置
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// staticPatternPath 是访问路径,后面的是上传的资源路径
// uploadFolder 是文件存储位置,而文件保存在uploadFolder 目录下
registry.addResourceHandler(staticPatternPath).addResourceLocations(uploadFolder);
}
}
三、阿里云OSS文件上传
1、 阿里云oss配置
首先开通阿里云oss,选择公共读,这样别人才可以读到我们的文件,但这样可能会导致上行流量剧增
创建玩Bucket后,需要配置一下ssl证书和已备案自定义域名,否则浏览器只能下载,不能读
最后获取AccessKey和SecretKey。进入 AccessKey管理 ,进入之后选择开始使用子用户AccessKey(推荐,这样安全),创建子用户,选择openAPI访问,创建完成后,添加AliyunOSSFullAccess权限
2、 Java整合oss
下面简单说一下配置,首先配置maven
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.10.2</version>
</dependency>
创建上传方法
public static String uploadFile(MultipartFile multipartFile) {
// yourEndpoint填写Bucket所在地域对应的Endpoint。以华东1(杭州)为例,Endpoint填写为https://oss-cn-hangzhou.aliyuncs.com。
String endpoint = "oss-cn-hangzhou.aliyuncs.com";
// 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。
String accessKeyId = "";
String accessKeySecret = "";
// 你的桶名字
String bucketName = "";
// 你的自定义域名,需要备案和配好ssl证书
String domainName = "";
// 桶里面你的根目录
String rootPath = "lamp";
OSS ossClient = null;
try {
// 创建OSSClient实例。
ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
// 获取文件上传的流
InputStream inputStream = multipartFile.getInputStream();
// 构建指定目录,按日期分类
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd");
String datePath = dateFormat.format(new Date());
// 获取文件名
String originName = multipartFile.getOriginalFilename();
String filename = UUID.randomUUID().toString();
String suffix = originName.substring(originName.lastIndexOf("."));
String newName = filename + suffix;
String fileUrl = rootPath + "/" + datePath + "/" + newName;
// 上传文件
ossClient.putObject(bucketName, fileUrl, inputStream);
return "https://" + domainName + "/" + fileUrl;
} catch (IOException e) {
e.printStackTrace();
return "fail";
} finally {
// 关闭OSSClient。
ossClient.shutdown();
}
}
3、 注意事项
使用 OSS 默认域名访问 html、图片资源,会有以附件形式下载的情况。若需要浏览器直接访问,需使用自定义域名进行访问,同时保证已经配置好ssl证书;同时oss桶还可以用来做图床
其他请参考官方文档
参考文献:
https://www.jianshu.com/p/be3f4c26c39a
https://www.cnblogs.com/zys2019/p/15394599.html
https://www.bilibili.com/video/BV1C3411b7wt?p=15&spm_id_from=pageDriver