diff --git a/src/main/java/im/zhaojun/zfile/admin/model/param/GoogleDriveParam.java b/src/main/java/im/zhaojun/zfile/admin/model/param/GoogleDriveParam.java new file mode 100644 index 0000000..55fd43b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/admin/model/param/GoogleDriveParam.java @@ -0,0 +1,43 @@ +package im.zhaojun.zfile.admin.model.param; + +import im.zhaojun.zfile.admin.annotation.StorageParamItem; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * Google Drive 初始化参数 + * + * @author zhaojun + */ +@Getter +@ToString +public class GoogleDriveParam extends ProxyTransferParam { + + @StorageParamItem(name = "clientId", defaultValue = "${zfile.gd.clientId}", order = 1, description = "默认 API 仅用作示例,因审核原因,目前不可用,请自行申请 API", link = "https://docs.zfile.vip/advanced#google-drive-api", linkName = "自定义 API 文档") + private String clientId; + + @StorageParamItem(name = "SecretKey", defaultValue = "${zfile.gd.clientSecret}", order = 2) + private String clientSecret; + + @StorageParamItem(name = "回调地址", description = "这里要修改为自己的域名", defaultValue = "${zfile.gd.redirectUri}", order = 3) + private String redirectUri; + + @Setter + @StorageParamItem(name = "访问令牌", link = "/gd/authorize", linkName = "前往获取令牌", order = 4) + private String accessToken; + + @Setter + @StorageParamItem(name = "刷新令牌", order = 5) + private String refreshToken; + + @StorageParamItem(name = "网盘", order = 6, required = false) + private String driveId; + + @StorageParamItem(name = "基路径", defaultValue = "/", order = 7, description = "基路径表示读取的根文件夹,不填写表示允许读取所有。如: '/','/文件夹1'") + private String basePath; + + @StorageParamItem(name = "加速域名", required = false, description = "可使用 cf worker index 程序的链接,会使用 cf 中转下载,教程自行查询. 不填写则使用服务器中转下载.") + private String domain; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/admin/model/request/gd/GetGdDriveListRequest.java b/src/main/java/im/zhaojun/zfile/admin/model/request/gd/GetGdDriveListRequest.java new file mode 100644 index 0000000..77cb251 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/admin/model/request/gd/GetGdDriveListRequest.java @@ -0,0 +1,20 @@ +package im.zhaojun.zfile.admin.model.request.gd; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * @author zhaojun + */ +@Data +@ApiModel(value="gd drive 列表请求类") +public class GetGdDriveListRequest { + + @NotBlank(message = "accessToken 不能为空") + @ApiModelProperty(value = "accessToken", required = true, example = "v7LtfjIbnxLCTj0R3riwhyxcbv4KVH5HuPWHWrrewHMEwjJyUlYXV6D4m1MLJ2dP__GX_7CKCc-HudUetPXWS2wwbfkNs6ydLq3xrk1gHA7wcD_pmt6oNuRXw5mnFzfdLkH5wIG1suQp3p0eHJurzIaCgYKATASATASFQE65dr8hO725r41QtZc9RJVUg12cA0163") + private String accessToken; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/admin/model/result/gd/GdDriveInfoResult.java b/src/main/java/im/zhaojun/zfile/admin/model/result/gd/GdDriveInfoResult.java new file mode 100644 index 0000000..d63331f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/admin/model/result/gd/GdDriveInfoResult.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.admin.model.result.gd; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * gd drive 基本信息结果类 + * + * @author zhaojun + */ +@Data +@AllArgsConstructor +@ApiModel(value="gd drive 基本信息结果类") +public class GdDriveInfoResult { + + @ApiModelProperty(value = "drive id", example = "0AGrY0xF1D7PEUk9PVB") + private String id; + + @ApiModelProperty(value = "drive 名称", example = "zfile") + private String name; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/common/context/StorageSourceContext.java b/src/main/java/im/zhaojun/zfile/common/context/StorageSourceContext.java index e9fd472..7e6d759 100644 --- a/src/main/java/im/zhaojun/zfile/common/context/StorageSourceContext.java +++ b/src/main/java/im/zhaojun/zfile/common/context/StorageSourceContext.java @@ -204,7 +204,11 @@ public class StorageSourceContext implements ApplicationContextAware { * @return 存储源对应的 Service */ public AbstractBaseFileService getByKey(String key) { - return get(storageSourceService.findIdByKey(key)); + Integer storageId = storageSourceService.findIdByKey(key); + if (storageId == null) { + return null; + } + return get(storageId); } diff --git a/src/main/java/im/zhaojun/zfile/common/controller/callback/GdCallbackController.java b/src/main/java/im/zhaojun/zfile/common/controller/callback/GdCallbackController.java new file mode 100644 index 0000000..a63fa51 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/common/controller/callback/GdCallbackController.java @@ -0,0 +1,141 @@ +package im.zhaojun.zfile.common.controller.callback; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONObject; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import im.zhaojun.zfile.admin.model.dto.OAuth2Token; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.net.HttpURLConnection; + +/** + * @author zhaojun + */ +@Api(tags = "Google Drive 认证回调模块") +@Controller +@Slf4j +@RequestMapping(value = {"/gd"}) +public class GdCallbackController { + + @Value("${zfile.gd.clientId}") + private String clientId; + + @Value("${zfile.gd.redirectUri}") + private String redirectUri; + + @Value("${zfile.gd.clientSecret}") + private String clientSecret; + + @Value("${zfile.gd.scope}") + private String scope; + + @GetMapping("/authorize") + @ApiOperationSupport(order = 1) + @ApiOperation(value = "生成 OAuth2 登陆 URL", notes = "生成 OneDrive OAuth2 登陆 URL,用户国际版,家庭版等非世纪互联运营的 OneDrive.") + public String authorize(String clientId, String clientSecret, String redirectUri) { + log.info("gd 生成授权链接参数信息: clientId: {}, clientSecret: {}, redirectUri: {}", clientId, clientSecret, redirectUri); + + if (StrUtil.isAllEmpty(clientId, clientSecret, redirectUri)) { + clientId = this.clientId; + redirectUri = this.redirectUri; + clientSecret = this.clientSecret; + } + + String stateStr = "&state=" + Base64.encodeUrlSafe(StrUtil.join("::", clientId, clientSecret, redirectUri)); + + String authorizeUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=" + clientId + + "&response_type=code&redirect_uri=" + redirectUri + + "&scope=" + this.scope + + "&access_type=offline" + + stateStr; + + log.info("gd 生成授权链接结果: {}", authorizeUrl); + + return "redirect:" + authorizeUrl; + } + + @GetMapping("/callback") + public String gdCallback(String code, String state, Model model) { + log.info("gd 授权回调参数信息: code: {}, state: {}", code, state); + + String clientId, clientSecret, redirectUri; + + if (StrUtil.isEmpty(state)) { + clientId = this.clientId; + clientSecret = this.clientSecret; + redirectUri = this.redirectUri; + } else { + String stateDecode = Base64.decodeStr(state); + String[] stateArr = stateDecode.split("::"); + clientId = stateArr[0]; + clientSecret = stateArr[1]; + redirectUri = stateArr[2]; + } + + final String uri = "https://accounts.google.com/o/oauth2/token"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String clientCredentials = Base64.encodeUrlSafe(clientId + ":" + clientSecret); + headers.add("Authorization", "Basic " + clientCredentials); + MultiValueMap requestBody = new LinkedMultiValueMap<>(); + requestBody.add("code", code); + requestBody.add("grant_type", "authorization_code"); + requestBody.add("redirect_uri", redirectUri); + requestBody.add("scope", scope); + + HttpEntity> formEntity = new HttpEntity<>(requestBody, headers); + + NoRedirectClientHttpRequestFactory noRedirectClientHttpRequestFactory = new NoRedirectClientHttpRequestFactory(); + + ResponseEntity response = new RestTemplate(noRedirectClientHttpRequestFactory).exchange(uri, HttpMethod.POST, formEntity, String.class); + + String body = response.getBody(); + log.info("{} 根据授权回调 code 获取令牌结果:body: {}", this, body); + + OAuth2Token oAuth2Token ; + if (response.getStatusCode() != HttpStatus.OK) { + oAuth2Token = OAuth2Token.fail(clientId, clientSecret, redirectUri, body); + } else { + JSONObject jsonBody = JSONObject.parseObject(body); + String accessToken = jsonBody.getString("access_token"); + String refreshToken = jsonBody.getString("refresh_token"); + oAuth2Token = + OAuth2Token.success(clientId, clientSecret, redirectUri, accessToken, refreshToken, body); + } + + model.addAttribute("oauth2Token", oAuth2Token); + model.addAttribute("type", "Google Drive"); + return "callback"; + } + + static class NoRedirectClientHttpRequestFactory extends + SimpleClientHttpRequestFactory { + + @Override + protected void prepareConnection(HttpURLConnection connection, + String httpMethod) throws IOException { + super.prepareConnection(connection, httpMethod); + connection.setInstanceFollowRedirects(true); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/common/controller/gd/GDHelperController.java b/src/main/java/im/zhaojun/zfile/common/controller/gd/GDHelperController.java new file mode 100644 index 0000000..fca54e2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/common/controller/gd/GDHelperController.java @@ -0,0 +1,60 @@ +package im.zhaojun.zfile.common.controller.gd; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import im.zhaojun.zfile.admin.model.request.gd.GetGdDriveListRequest; +import im.zhaojun.zfile.admin.model.result.gd.GdDriveInfoResult; +import im.zhaojun.zfile.common.util.AjaxJson; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import javax.validation.Valid; +import java.util.ArrayList; +import java.util.List; + +/** + * @author zhaojun + */ +@Api(tags = "gd 工具辅助模块") +@Controller +@RequestMapping("/gd") +public class GDHelperController { + + @PostMapping("/drives") + @ResponseBody + @ApiOperationSupport(order = 1) + @ApiOperation(value = "获取 gd drives 列表") + public AjaxJson> getDrives(@Valid @RequestBody GetGdDriveListRequest gdDriveListRequest) { + List bucketNameList = new ArrayList<>(); + String accessToken = gdDriveListRequest.getAccessToken(); + + HttpRequest httpRequest = HttpUtil.createGet("https://www.googleapis.com/drive/v3/drives"); + httpRequest.header("Authorization", "Bearer " + accessToken); + + HttpResponse httpResponse = httpRequest.execute(); + + String body = httpResponse.body(); + JSONObject jsonObject = JSON.parseObject(body); + JSONArray drives = jsonObject.getJSONArray("drives"); + + for (int i = 0; i < drives.size(); i++) { + JSONObject drive = drives.getJSONObject(i); + String id = drive.getString("id"); + String name = drive.getString("name"); + bucketNameList.add(new GdDriveInfoResult(id, name)); + } + + return AjaxJson.getSuccessData(bucketNameList); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/common/exception/file/StorageSourceException.java b/src/main/java/im/zhaojun/zfile/common/exception/file/StorageSourceException.java index 144f812..4a4f510 100644 --- a/src/main/java/im/zhaojun/zfile/common/exception/file/StorageSourceException.java +++ b/src/main/java/im/zhaojun/zfile/common/exception/file/StorageSourceException.java @@ -29,4 +29,9 @@ public class StorageSourceException extends RuntimeException { this.storageId = storageId; } + public StorageSourceException(Integer storageId, String message, Throwable cause) { + super(message, cause); + this.storageId = storageId; + } + } \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/home/model/dto/StorageSourceAllParam.java b/src/main/java/im/zhaojun/zfile/home/model/dto/StorageSourceAllParam.java index 6242d01..62754e8 100644 --- a/src/main/java/im/zhaojun/zfile/home/model/dto/StorageSourceAllParam.java +++ b/src/main/java/im/zhaojun/zfile/home/model/dto/StorageSourceAllParam.java @@ -105,5 +105,8 @@ public class StorageSourceAllParam { @ApiModelProperty(value = "编码格式", example = "UTF-8") private String encoding; - + + @ApiModelProperty(value = "存储源 ID", example = "0AGrY0xF1D7PEUk9PV2") + private String driveId; + } \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/home/model/enums/StorageTypeEnum.java b/src/main/java/im/zhaojun/zfile/home/model/enums/StorageTypeEnum.java index 932c5bc..9d2e396 100644 --- a/src/main/java/im/zhaojun/zfile/home/model/enums/StorageTypeEnum.java +++ b/src/main/java/im/zhaojun/zfile/home/model/enums/StorageTypeEnum.java @@ -34,6 +34,7 @@ public enum StorageTypeEnum implements IEnum { ONE_DRIVE_CHINA("onedrive-china", "OneDrive 世纪互联"), SHAREPOINT_DRIVE("sharepoint", "SharePoint"), SHAREPOINT_DRIVE_CHINA("sharepoint-china", "SharePoint 世纪互联"), + GOOGLE_DRIVE("google-drive", "Google Drive"), QINIU("qiniu", "七牛云 KODO"); private static final Map ENUM_MAP = new HashMap<>(); diff --git a/src/main/java/im/zhaojun/zfile/home/service/impl/GoogleDriveServiceImpl.java b/src/main/java/im/zhaojun/zfile/home/service/impl/GoogleDriveServiceImpl.java new file mode 100644 index 0000000..c4c3ef2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/home/service/impl/GoogleDriveServiceImpl.java @@ -0,0 +1,605 @@ +package im.zhaojun.zfile.home.service.impl; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import im.zhaojun.zfile.admin.constant.StorageConfigConstant; +import im.zhaojun.zfile.admin.model.dto.OAuth2Token; +import im.zhaojun.zfile.admin.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.admin.model.param.GoogleDriveParam; +import im.zhaojun.zfile.admin.service.StorageSourceConfigService; +import im.zhaojun.zfile.common.cache.RefreshTokenCache; +import im.zhaojun.zfile.common.exception.StorageSourceRefreshTokenException; +import im.zhaojun.zfile.common.exception.file.StorageSourceException; +import im.zhaojun.zfile.common.util.RequestHolder; +import im.zhaojun.zfile.common.util.StringUtils; +import im.zhaojun.zfile.home.model.enums.FileTypeEnum; +import im.zhaojun.zfile.home.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.home.model.result.FileItemResult; +import im.zhaojun.zfile.home.service.base.ProxyTransferService; +import im.zhaojun.zfile.home.service.base.RefreshTokenService; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHeaders; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class GoogleDriveServiceImpl extends ProxyTransferService implements RefreshTokenService { + + /** + * 文件类型:文件夹 + */ + private static final String FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + + /** + * 文件类型:快捷方式 + */ + private static final String SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"; + + /** + * 文件基础操作 API + */ + private static final String DRIVE_FILE_URL = "https://www.googleapis.com/drive/v3/files"; + + + /** + * 文件上传操作 API + */ + private static final String DRIVE_FILE_UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart"; + + /** + * 刷新 AccessToken URL + */ + private static final String REFRESH_TOKEN_URL = "https://oauth2.googleapis.com/token"; + + @javax.annotation.Resource + private StorageSourceConfigService storageSourceConfigService; + + @Override + public void init() { + refreshAccessToken(); + } + + /** + * 根据路径获取文件/文件夹 id + * + * @param path + * 路径 + * + * @return 文件/文件夹 id + */ + private String getIdByPath(String path) { + String fullPath = StringUtils.concat(param.getBasePath(), path); + if (StrUtil.isEmpty(fullPath) || StrUtil.equals(fullPath, "/")) { + return StrUtil.isEmpty(param.getDriveId()) ? "root" : param.getDriveId(); + } + + List pathList = StrUtil.splitTrim(fullPath, "/"); + + String driveId = ""; + for (String subPath : pathList) { + String folderIdParam = new GoogleDriveAPIParam().getDriveIdByPathParam(subPath, driveId); + HttpRequest httpRequest = HttpUtil.createGet(DRIVE_FILE_URL); + httpRequest.header("Authorization", "Bearer " + param.getAccessToken()); + httpRequest.body(folderIdParam); + + HttpResponse httpResponse = httpRequest.execute(); + + if (HttpStatus.valueOf(httpResponse.getStatus()).isError()) { + log.info("根据文件夹路径获取文件夹id失败, storageId: {},folderPath: {}, httpResponse body: {}", storageId, path, httpResponse.body()); + throw new StorageSourceException(storageId, "文件目录不存在或请求异常[1]"); + } + + String body = httpResponse.body(); + + JSONObject jsonObject = JSON.parseObject(body); + JSONArray files = jsonObject.getJSONArray("files"); + + if (files.size() == 0) { + throw new StorageSourceException(storageId, "文件目录不存在或请求异常[2]"); + } + + driveId = files.getJSONObject(0).getString("id"); + } + + return driveId; + } + + @Override + public List fileList(String folderPath) throws Exception { + List result = new ArrayList<>(); + + String folderId = getIdByPath(folderPath); + String pageToken = ""; + do { + String folderIdParam = new GoogleDriveAPIParam().getFileListParam(folderId, pageToken); + HttpRequest httpRequest = HttpUtil.createGet(DRIVE_FILE_URL); + httpRequest.header("Authorization", "Bearer " + param.getAccessToken()); + httpRequest.body(folderIdParam); + + HttpResponse httpResponse = httpRequest.execute(); + + if (HttpStatus.valueOf(httpResponse.getStatus()).isError()) { + log.info("根据文件夹路径获取文件夹列表失败, storageId: {},folderPath: {}, httpResponse body: {}", storageId, folderPath, httpResponse.body()); + throw new StorageSourceException(storageId, "文件目录不存在或请求异常[3]"); + } + + String body = httpResponse.body(); + + JSONObject jsonObject = JSON.parseObject(body); + pageToken = jsonObject.getString("nextPageToken"); + JSONArray files = jsonObject.getJSONArray("files"); + result.addAll(jsonArrayToFileList(files, folderPath)); + } while (StrUtil.isNotEmpty(pageToken)); + + return result; + } + + @Override + public FileItemResult getFileItem(String pathAndName) { + String fileId = getIdByPath(pathAndName); + + String folderName = FileUtil.getParent(pathAndName, 1); + + HttpRequest httpRequest = HttpUtil.createGet(DRIVE_FILE_URL + "/" + fileId); + httpRequest.header("Authorization", "Bearer " + param.getAccessToken()); + httpRequest.body("fields=id,name,mimeType,shortcutDetails,size,modifiedTime"); + HttpResponse httpResponse = httpRequest.execute(); + + if (HttpStatus.valueOf(httpResponse.getStatus()).isError()) { + log.info("根据文件路径获取文件详情失败, storageId: {},pathAndName: {}, httpResponse body: {}", storageId, pathAndName, httpResponse.body()); + throw new StorageSourceException(storageId, "文件不存在或请求异常"); + } + + String body = httpResponse.body(); + JSONObject jsonObject = JSON.parseObject(body); + return jsonObjectToFileItem(jsonObject, folderName); + } + + + @Override + public boolean newFolder(String path, String name) { + HttpResponse httpResponse = HttpRequest.post(DRIVE_FILE_URL) + .header("Authorization", "Bearer " + param.getAccessToken()) + .body(new JSONObject() + .fluentPut("name", name) + .fluentPut("mimeType", FOLDER_MIME_TYPE) + .fluentPut("parents", Collections.singletonList(getIdByPath(path))) + .toJSONString()) + .execute(); + + if (HttpStatus.valueOf(httpResponse.getStatus()).isError()) { + log.info("新建文件夹失败, storageId: {},path: {}, name: {}, httpResponse body: {}", storageId, path, name, httpResponse.body()); + throw new StorageSourceException(storageId, "新建文件夹失败"); + } + return true; + } + + @Override + public boolean deleteFile(String path, String name) { + String pathAndName = StringUtils.concat(path, name); + HttpResponse httpResponse = HttpRequest.delete(DRIVE_FILE_URL + "/" + getIdByPath(pathAndName)) + .header("Authorization", "Bearer " + param.getAccessToken()) + .execute(); + + if (HttpStatus.valueOf(httpResponse.getStatus()).isError()) { + log.info("删除文件/文件夹失败, storageId: {},pathAndName: {}, httpResponse body: {}", storageId, pathAndName, httpResponse.body()); + throw new StorageSourceException(storageId, "删除失败"); + } + return true; + } + + @Override + public boolean deleteFolder(String path, String name) { + return deleteFile(path, name); + } + + @Override + public boolean renameFile(String path, String name, String newName) { + String pathAndName = StringUtils.concat(path, name); + String fileId = getIdByPath(pathAndName); + + HttpResponse httpResponse = HttpRequest.patch(DRIVE_FILE_URL + "/" + fileId) + .header("Authorization", "Bearer " + param.getAccessToken()) + .body(new JSONObject() + .fluentPut("name", newName) + .toJSONString()) + .execute(); + + if (HttpStatus.valueOf(httpResponse.getStatus()).isError()) { + log.info("重命名文件/文件夹失败, storageId: {},path: {}, name: {}, httpResponse body: {}", storageId, path, name, httpResponse.body()); + throw new StorageSourceException(storageId, "重命名文件/文件夹失败"); + } + return true; + } + + @Override + public boolean renameFolder(String path, String name, String newName) { + return renameFile(path, name, newName); + } + + @Override + public void uploadFile(String pathAndName, InputStream inputStream) { + String boundary = IdUtil.fastSimpleUUID(); + String fileName = FileUtil.getName(pathAndName); + String folderName = StringUtils.getParentPath(pathAndName); + + String jsonString = new JSONObject() + .fluentPut("name", fileName) + .fluentPut("parents", Collections.singletonList(getIdByPath(folderName))) + .toJSONString(); + HttpEntity entity = MultipartEntityBuilder.create() + .setMimeSubtype("related") + .setBoundary(boundary) + .addTextBody(boundary, jsonString, ContentType.APPLICATION_JSON) + .addBinaryBody(boundary, inputStream) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpUriRequest httpUriRequest = RequestBuilder.post(DRIVE_FILE_UPLOAD_URL) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + param.getAccessToken()) + .setEntity(entity) + .build(); + + CloseableHttpResponse response = httpClient.execute(httpUriRequest); + StatusLine statusLine = response.getStatusLine(); + if (HttpStatus.valueOf(statusLine.getStatusCode()).isError()) { + HttpEntity responseEntity = response.getEntity(); + String responseBody = EntityUtils.toString(responseEntity, StandardCharsets.UTF_8); + log.error("上传文件失败, storageId: {},pathAndName: {}, httpResponse body: {}", storageId, pathAndName, responseBody); + throw new StorageSourceException(storageId, "上传文件失败:" + responseBody); + } + } catch (IOException e) { + log.error("上传文件失败, storageId: {},pathAndName: {}, httpResponse body: {}", storageId, pathAndName, e); + throw new StorageSourceException(storageId, "上传文件失败:" + e.getMessage(), e); + } + } + + @Override + public ResponseEntity downloadToStream(String pathAndName) { + String fileId = getIdByPath(pathAndName); + + HttpServletRequest request = RequestHolder.getRequest(); + + HttpRequest httpRequest = HttpUtil.createGet(DRIVE_FILE_URL + "/" + fileId); + httpRequest.header("Authorization", "Bearer " + param.getAccessToken()); + httpRequest.body("alt=media"); + httpRequest.header(HttpHeaders.RANGE, request.getHeader(HttpHeaders.RANGE)); + HttpResponse httpResponse = httpRequest.executeAsync(); + if (HttpStatus.valueOf(httpResponse.getStatus()).isError()) { + log.info("GoogleDrive下载文件失败, storageId: {},pathAndName: {}", storageId, pathAndName); + throw new StorageSourceException(storageId, "下载文件失败"); + } + + try { + HttpServletResponse response = RequestHolder.getResponse(); + response.setHeader(HttpHeaders.CONTENT_RANGE, httpResponse.header(HttpHeaders.CONTENT_RANGE)); + response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes"); + response.setHeader(HttpHeaders.CONTENT_LENGTH, httpResponse.header(HttpHeaders.CONTENT_LENGTH)); + response.setContentType(httpResponse.header(HttpHeaders.CONTENT_TYPE)); + OutputStream outputStream = response.getOutputStream(); + httpResponse.writeBody(outputStream, true, null); + } catch (IOException e) { + throw new RuntimeException("下载文件失败:" + e.getMessage(), e); + } + return null; + } + + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.GOOGLE_DRIVE; + } + + /** + * 根据 RefreshToken 刷新 AccessToken, 返回刷新后的 Token. + * + * @return 刷新后的 Token + */ + public OAuth2Token getRefreshToken() { + StorageSourceConfig refreshStorageSourceConfig = + storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY); + + String paramStr = "client_id=" + param.getClientId() + + "&client_secret=" + param.getClientSecret() + + "&refresh_token=" + refreshStorageSourceConfig.getValue() + + "&grant_type=refresh_token" + + "&access_type=offline"; + + log.info("{} 尝试刷新令牌, 参数信息为: {}", this, param); + + HttpRequest post = HttpUtil.createPost(REFRESH_TOKEN_URL); + + post.body(paramStr); + HttpResponse response = post.execute(); + String body = response.body(); + log.info("{} 尝试刷新令牌成功, 响应信息为: {}", this, body); + + JSONObject jsonBody = JSONObject.parseObject(body); + + if (response.getStatus() != HttpStatus.OK.value()) { + return OAuth2Token.fail(param.getClientId(), param.getClientSecret(), param.getRedirectUri(), body); + } + + String accessToken = jsonBody.getString("access_token"); + return OAuth2Token.success(param.getClientId(), param.getClientSecret(), param.getRedirectUri(), accessToken, null, body); + } + + /** + * 刷新当前存储源 AccessToken + */ + @Override + public void refreshAccessToken() { + try { + OAuth2Token refreshToken = getRefreshToken(); + + if (refreshToken.getAccessToken() == null) { + throw new StorageSourceRefreshTokenException("获取 AccessToken 失败, 获取到的令牌为空, 相关诊断信息为: " + refreshToken, storageId); + } + + StorageSourceConfig accessTokenConfig = + storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.ACCESS_TOKEN_KEY); + accessTokenConfig.setValue(refreshToken.getAccessToken()); + + storageSourceConfigService.updateStorageConfig(Collections.singletonList(accessTokenConfig)); + RefreshTokenCache.putRefreshTokenInfo(storageId, RefreshTokenCache.RefreshTokenInfo.success()); + param.setAccessToken(refreshToken.getAccessToken()); + param.setRefreshToken(refreshToken.getRefreshToken()); + log.info("存储源 {} 刷新 AccessToken 成功", storageId); + } catch (Exception e) { + RefreshTokenCache.putRefreshTokenInfo(storageId, RefreshTokenCache.RefreshTokenInfo.fail(getStorageTypeEnum().getDescription() + " AccessToken 刷新失败: " + e.getMessage())); + throw new StorageSourceRefreshTokenException("存储源 ID: [{}] 刷新 AccessToken 失败", e, storageId); + } + } + + /** + * 转换 api 返回的 json array 为 zfile 文件对象列表 + * + * @param jsonArray + * api 返回文件 json array + * + * @param folderPath + * 所属文件夹路径 + * + * @return zfile 文件对象列表 + */ + public List jsonArrayToFileList(JSONArray jsonArray, String folderPath) { + ArrayList fileList = new ArrayList<>(); + + for (int i = 0; i < jsonArray.size(); i++) { + fileList.add(jsonObjectToFileItem(jsonArray.getJSONObject(i), folderPath)); + } + + return fileList; + } + + + /** + * 转换 api 返回的 json object 为 zfile 文件对象 + * + * @param jsonObject + * api 返回文件 json object + * + * @param folderPath + * 所属文件夹路径 + * + * @return zfile 文件对象 + */ + public FileItemResult jsonObjectToFileItem(JSONObject jsonObject, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(jsonObject.getString("name")); + fileItemResult.setPath(folderPath); + fileItemResult.setSize(jsonObject.getLong("size")); + + String mimeType = jsonObject.getString("mimeType"); + if (ObjectUtil.equals(SHORTCUT_MIME_TYPE, mimeType)) { + JSONObject shortcutDetails = jsonObject.getJSONObject("shortcutDetails"); + mimeType = shortcutDetails.getString("targetMimeType"); + } + + if (StrUtil.equals(mimeType, FOLDER_MIME_TYPE)) { + fileItemResult.setType(FileTypeEnum.FOLDER); + } else { + fileItemResult.setType(FileTypeEnum.FILE); + fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(folderPath, fileItemResult.getName()))); + } + + fileItemResult.setTime(jsonObject.getDate("modifiedTime")); + + if (fileItemResult.getSize() == null) { + fileItemResult.setSize(-1L); + } + + return fileItemResult; + } + + + /** + * 请求参数类 + */ + @Data + class GoogleDriveAPIParam { + + private final Integer DEFAULT_PAGE_SIZE = 1000; + + // 存储源 id + private String driveId; + + // 是否返回共享驱动器或团队盘的内容 + private boolean includeItemsFromAllDrives; + + // 查询适用的文件分组, 支持 'user', 'drive', 'allDrives' + private String corpora; + + // 请求的应用程序是否同时支持“我的云端硬盘”和共享云端硬盘 + private boolean supportsAllDrives; + + // 请求的字段 + private String fields; + + // 查询参数 + private String q; + + // 每页多少条 + private Integer pageSize; + + // 下页的页码 + private String pageToken; + + /** + * 根据路径获取 id 的 api 请求参数 + * + * @param folderPath + * 文件夹路径 + */ + public String getDriveIdByPathParam(String folderPath, String parentId) { + GoogleDriveAPIParam googleDriveAPIParam = getBasicParam(); + + String parentIdParam = ""; + + if (StrUtil.isNotEmpty(parentId)) { + parentIdParam = "'" + parentId + "' in parents and "; + } + + googleDriveAPIParam.setFields("files(id)"); + googleDriveAPIParam.setQ(parentIdParam + " name = '" + folderPath + "' and trashed = false"); + + return googleDriveAPIParam.toString(); + } + + + /** + * 根据路径获取 id 的 api 请求参数 + * + * @param folderId + * google drive 文件夹 id + * + * @param pageToken + * 分页 token + */ + public String getFileListParam(String folderId, String pageToken) { + GoogleDriveAPIParam googleDriveAPIParam = getBasicParam(); + + googleDriveAPIParam.setFields("files(id,name,mimeType,shortcutDetails,size,modifiedTime),nextPageToken"); + googleDriveAPIParam.setQ("'" + folderId + "' in parents and trashed = false"); + googleDriveAPIParam.setPageToken(pageToken); + googleDriveAPIParam.setPageSize(DEFAULT_PAGE_SIZE); + return googleDriveAPIParam.toString(); + } + + + /** + * 根据关键字和路径搜索文件 api 请求参数 + * + * @param folderId + * 搜索的父文件夹 id + * + * @param pageToken + * 分页 token + * + * @param keyword + * 搜索关键字 + */ + public String getSearchParam(String folderId, String pageToken, String keyword) { + GoogleDriveAPIParam googleDriveAPIParam = getBasicParam(); + + String parentIdParam = ""; + if (StrUtil.isNotEmpty(folderId)) { + parentIdParam = "'" + folderId + "' in parents and "; + } + + googleDriveAPIParam.setFields("files(id,name,mimeType,shortcutDetails,size,modifiedTime),nextPageToken"); + googleDriveAPIParam.setQ(parentIdParam + " name contains '" + keyword + "' and trashed = false"); + googleDriveAPIParam.setPageToken(pageToken); + googleDriveAPIParam.setPageSize(DEFAULT_PAGE_SIZE); + return googleDriveAPIParam.toString(); + } + + + /** + * 判断是否是团队盘,填充基础参数 + */ + public GoogleDriveAPIParam getBasicParam() { + GoogleDriveAPIParam googleDriveAPIParam = new GoogleDriveAPIParam(); + String driveId = param.getDriveId(); + + // 判断是否是团队盘,如果是,则需要添加团队盘的参数 + boolean isTeamDrive = StrUtil.isNotEmpty(driveId); + + googleDriveAPIParam.setCorpora("user"); + if (isTeamDrive) { + googleDriveAPIParam.setDriveId(driveId); + googleDriveAPIParam.setIncludeItemsFromAllDrives(true); + googleDriveAPIParam.setSupportsAllDrives(true); + googleDriveAPIParam.setCorpora("drive"); + } + + return googleDriveAPIParam; + } + + /** + * 请求对象转 url param string + * + * @return url param string + */ + @Override + public String toString() { + Field[] fields = ReflectUtil.getFields(this.getClass()); + + StringBuilder param = new StringBuilder(); + + for (Field field : fields) { + if (StrUtil.startWith(field.getName(), "this")) { + continue; + } + Object fieldValue = ReflectUtil.getFieldValue(this, field); + + if (ObjectUtil.isNotEmpty(fieldValue) && ObjectUtil.notEqual(fieldValue, false)) { + param.append(field.getName()).append("=").append(fieldValue).append("&"); + } + } + + param.deleteCharAt(param.length() - 1); + return param.toString(); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application-default.properties b/src/main/resources/application-default.properties index bd67f32..835d175 100644 --- a/src/main/resources/application-default.properties +++ b/src/main/resources/application-default.properties @@ -1 +1 @@ -# onedrive config zfile.onedrive.clientId=09939809-c617-43c8-a220-a93c1513c5d4 zfile.onedrive.clientSecret=_l:zI-_yrW75lV8M61K@z.I2K@B/On6Q zfile.onedrive.redirectUri=https://zfile.jun6.net/onedrive/callback zfile.onedrive.scope=offline_access User.Read Files.ReadWrite.All Sites.Read.All Sites.ReadWrite.All # onedrive china config zfile.onedrive-china.clientId=4a72d927-1907-488d-9eb2-1b465c53c1c5 zfile.onedrive-china.clientSecret=Y9CEA=82da5n-y_]KAWAgLH3?R9xf7Uw zfile.onedrive-china.redirectUri=https://zfile.jun6.net/onedrive/china-callback zfile.onedrive-china.scope=offline_access User.Read Files.ReadWrite.All Sites.Read.All Sites.ReadWrite.All # result config spring.jackson.date-format=yyyy-MM-dd HH:mm spring.jackson.time-zone=GMT+8 spring.web.resources.chain.compressed=true ## mybatis config mybatis-plus.configuration.map-underscore-to-camel-case=true mybatis-plus.mapper-locations=classpath*:mapper/*.xml,classpath*:com/gitee/sunchenbin/mybatis/actable/mapping/*/*.xml ## flyway config spring.flyway.clean-disabled=true spring.flyway.enabled=false # knife4j config knife4j.enable=true knife4j.setting.enableSwaggerModels=true # sa-token config sa-token.is-print=false sa-token.token-name=zfile-token spring.main.allow-circular-references=true spring.servlet.multipart.max-request-size=-1 spring.servlet.multipart.max-file-size=-1 mybatis-plus.configuration.default-enum-type-handler=im.zhaojun.zfile.common.config.MybatisEnumTypeHandler spring.mvc.pathmatch.matching-strategy=ant_path_matcher server.compression.enabled=true \ No newline at end of file +# onedrive config zfile.onedrive.clientId=09939809-c617-43c8-a220-a93c1513c5d4 zfile.onedrive.clientSecret=_l:zI-_yrW75lV8M61K@z.I2K@B/On6Q zfile.onedrive.redirectUri=https://zfile.jun6.net/onedrive/callback zfile.onedrive.scope=offline_access User.Read Files.ReadWrite.All Sites.Read.All Sites.ReadWrite.All # onedrive china config zfile.onedrive-china.clientId=4a72d927-1907-488d-9eb2-1b465c53c1c5 zfile.onedrive-china.clientSecret=Y9CEA=82da5n-y_]KAWAgLH3?R9xf7Uw zfile.onedrive-china.redirectUri=https://zfile.jun6.net/onedrive/china-callback zfile.onedrive-china.scope=offline_access User.Read Files.ReadWrite.All Sites.Read.All Sites.ReadWrite.All # gd config zfile.gd.clientId=659016983345-vlp413rgrl2spe5d53ml16p2btslfa44.apps.googleusercontent.com zfile.gd.clientSecret=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 zfile.gd.redirectUri=http://localhost:8080/gd/callback zfile.gd.scope=https://www.googleapis.com/auth/drive # result config spring.jackson.date-format=yyyy-MM-dd HH:mm spring.jackson.time-zone=GMT+8 spring.web.resources.chain.compressed=true ## mybatis config mybatis-plus.configuration.map-underscore-to-camel-case=true mybatis-plus.mapper-locations=classpath*:mapper/*.xml,classpath*:com/gitee/sunchenbin/mybatis/actable/mapping/*/*.xml ## flyway config spring.flyway.clean-disabled=true spring.flyway.enabled=false # knife4j config knife4j.enable=true knife4j.setting.enableSwaggerModels=true # sa-token config sa-token.is-print=false sa-token.token-name=zfile-token spring.main.allow-circular-references=true spring.servlet.multipart.max-request-size=-1 spring.servlet.multipart.max-file-size=-1 mybatis-plus.configuration.default-enum-type-handler=im.zhaojun.zfile.common.config.MybatisEnumTypeHandler spring.mvc.pathmatch.matching-strategy=ant_path_matcher server.compression.enabled=true \ No newline at end of file