From 2717dcf58be434bb7b00fe5f737566e154d86166 Mon Sep 17 00:00:00 2001 From: lianlonggang Date: Wed, 22 Apr 2026 14:11:54 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E9=A1=B9=E7=9B=AE=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 29 ++ pom.xml | 109 ++++++++ src/main/java/com/yxtech/ossutil/App.java | 11 + .../yxtech/ossutil/config/XjOssConfig.java | 44 +++ .../controller/SysUploadController.java | 127 +++++++++ .../com/yxtech/ossutil/util/XjOssUtil.java | 250 ++++++++++++++++++ src/main/resources/app.yml | 26 ++ src/test/java/features/HelloTest.java | 19 ++ 8 files changed, 615 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/com/yxtech/ossutil/App.java create mode 100644 src/main/java/com/yxtech/ossutil/config/XjOssConfig.java create mode 100644 src/main/java/com/yxtech/ossutil/controller/SysUploadController.java create mode 100644 src/main/java/com/yxtech/ossutil/util/XjOssUtil.java create mode 100644 src/main/resources/app.yml create mode 100644 src/test/java/features/HelloTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf2a19c --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +*.log +*.flattened-pom.xml + +### NetBeans ### +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ + +### Mac files ### +*.DS_Store \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f11e01c --- /dev/null +++ b/pom.xml @@ -0,0 +1,109 @@ + + + 4.0.0 + + + org.noear + solon-parent + 3.10.0 + + + + com.yxtech + ossutil + 1.0 + + jar + + watch valid + + + 21 + + + + + + org.noear + solon-boot-smarthttp + + + + org.noear + solon-logging-logback-jakarta + + + + org.projectlombok + lombok + provided + + + + org.noear + solon-test + test + + + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + org.apache.httpcomponents + httpmime + 4.5.14 + + + + + org.noear + solon-config-yaml + + + + + com.alibaba.fastjson2 + fastjson2 + 2.0.53 + + + + + ${project.artifactId} + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.noear + solon-maven-plugin + + + + + + + tencent + https://mirrors.cloud.tencent.com/nexus/repository/maven-public/ + + false + + + + + \ No newline at end of file diff --git a/src/main/java/com/yxtech/ossutil/App.java b/src/main/java/com/yxtech/ossutil/App.java new file mode 100644 index 0000000..66071f1 --- /dev/null +++ b/src/main/java/com/yxtech/ossutil/App.java @@ -0,0 +1,11 @@ +package com.yxtech.ossutil; + +import org.noear.solon.Solon; +import org.noear.solon.annotation.SolonMain; + +@SolonMain +public class App { + public static void main(String[] args) { + Solon.start(App.class, args); + } +} \ No newline at end of file diff --git a/src/main/java/com/yxtech/ossutil/config/XjOssConfig.java b/src/main/java/com/yxtech/ossutil/config/XjOssConfig.java new file mode 100644 index 0000000..5966df8 --- /dev/null +++ b/src/main/java/com/yxtech/ossutil/config/XjOssConfig.java @@ -0,0 +1,44 @@ +package com.yxtech.ossutil.config; + +import com.yxtech.ossutil.util.XjOssUtil; +import lombok.extern.slf4j.Slf4j; +import org.noear.solon.annotation.Configuration; +import org.noear.solon.annotation.Init; +import org.noear.solon.annotation.Inject; + +/** + * IOSP OSS 配置,从 app.yml 读取并注入到 XjOssUtil 静态字段 + */ +@Slf4j +@Configuration +public class XjOssConfig { + + @Inject("${xj.oss.gateway-url}") + private String gatewayUrl; + @Inject("${xj.oss.client-id}") + private String clientId; + @Inject("${xj.oss.client-secret}") + private String clientSecret; + @Inject("${xj.oss.organization-id}") + private String organizationId; + @Inject("${xj.oss.config-code}") + private String configCode; + @Inject("${xj.oss.directory}") + private String directory; + /** 逗号分隔的扩展名白名单,如 jpg,png,pdf;为空则不限制 */ + @Inject("${xj.oss.file-type-white-list:}") + private String fileTypeWhiteList; + + @Init + public void init() { + XjOssUtil.setGatewayUrl(gatewayUrl); + XjOssUtil.setClientId(clientId); + XjOssUtil.setClientSecret(clientSecret); + XjOssUtil.setOrganizationId(organizationId); + XjOssUtil.setConfigCode(configCode); + XjOssUtil.setDirectory(directory); + XjOssUtil.setFileTypeWhiteList(fileTypeWhiteList); + log.info("[XjOssConfig] 初始化完成,网关: {},白名单: {}", gatewayUrl, + fileTypeWhiteList.isBlank() ? "不限制" : fileTypeWhiteList); + } +} diff --git a/src/main/java/com/yxtech/ossutil/controller/SysUploadController.java b/src/main/java/com/yxtech/ossutil/controller/SysUploadController.java new file mode 100644 index 0000000..9de2d31 --- /dev/null +++ b/src/main/java/com/yxtech/ossutil/controller/SysUploadController.java @@ -0,0 +1,127 @@ +package com.yxtech.ossutil.controller; + +import com.yxtech.ossutil.util.XjOssUtil; +import lombok.extern.slf4j.Slf4j; +import org.noear.solon.annotation.Controller; +import org.noear.solon.annotation.Get; +import org.noear.solon.annotation.Mapping; +import org.noear.solon.core.handle.Context; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * IOSP OSS 文件下载与预览 + * - /sys/upload/down/** 强制下载 + * - /sys/upload/show/** 浏览器内联预览 + */ +@Slf4j +@Controller +@Mapping("/") +public class SysUploadController { + + @Get + @Mapping("/down/**") + public void fileDown(Context ctx) throws Exception { + serve(ctx, "/down/", true); + } + + @Get + @Mapping("/show/**") + public void fileShow(Context ctx) throws Exception { + serve(ctx, "/show/", false); + } + + // ---- 核心逻辑 ---- + + private void serve(Context ctx, String marker, boolean download) throws Exception { + String fileKey = extractFileKey(ctx, marker); + if (isIllegalFileKey(fileKey)) { + ctx.status(400); + ctx.output("fileKey 格式非法"); + return; + } + String fileName = fileKey.contains("@") ? fileKey.substring(fileKey.indexOf("@") + 1) : fileKey; + String ext = ext(fileName); + + // 白名单校验 + try { + XjOssUtil.checkExt(ext); + } catch (IllegalArgumentException e) { + ctx.status(403); + ctx.output(e.getMessage()); + return; + } + + try (InputStream in = XjOssUtil.down(fileKey)) { + if (in == null) { + ctx.status(404); + ctx.output("文件不存在或下载失败"); + return; + } + String disposition = download ? "attachment" : "inline"; + ctx.contentType(download ? "application/octet-stream" : resolveContentType(ext)); + ctx.headerSet("Content-Disposition", + disposition + ";filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); + try (OutputStream out = ctx.outputStream()) { + byte[] buf = new byte[8192]; + int len; + while ((len = in.read(buf)) != -1) out.write(buf, 0, len); + out.flush(); + } + } catch (Exception e) { + log.error("[SysUploadController] 文件服务失败, fileKey={}, download={}", fileKey, download, e); + } + } + + // ---- 工具方法 ---- + + private String extractFileKey(Context ctx, String marker) { + String path = ctx.path(); + int idx = path.indexOf(marker); + if (idx < 0) return ""; + return URLDecoder.decode(path.substring(idx + marker.length()), StandardCharsets.UTF_8); + } + + private boolean isIllegalFileKey(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return true; + // 长度限制,防止资源耗尽 + if (fileKey.length() > 512) return true; + // null byte 注入(可绕过扩展名校验) + if (fileKey.contains("\0")) return true; + // 绝对路径 + if (fileKey.startsWith("/") || fileKey.startsWith("\\")) return true; + // 路径穿透:同时检查原始值和二次解码值(防双重编码绕过) + String decoded = URLDecoder.decode(fileKey, StandardCharsets.UTF_8); + return hasTraversal(fileKey) || hasTraversal(decoded); + } + + private boolean hasTraversal(String s) { + return s.contains("../") || s.contains("..\\") || s.startsWith("..") || s.contains("/.."); + } + + private String ext(String fileName) { + int dot = fileName.lastIndexOf('.'); + return dot >= 0 ? fileName.substring(dot + 1).toLowerCase() : ""; + } + + private String resolveContentType(String ext) { + return switch (ext) { + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "bmp" -> "image/bmp"; + case "svg" -> "image/svg+xml"; + case "pdf" -> "application/pdf"; + case "mp4" -> "video/mp4"; + case "mp3" -> "audio/mpeg"; + case "txt" -> "text/plain;charset=UTF-8"; + case "html", "htm" -> "text/html;charset=UTF-8"; + default -> "application/octet-stream"; + }; + } +} diff --git a/src/main/java/com/yxtech/ossutil/util/XjOssUtil.java b/src/main/java/com/yxtech/ossutil/util/XjOssUtil.java new file mode 100644 index 0000000..89adfed --- /dev/null +++ b/src/main/java/com/yxtech/ossutil/util/XjOssUtil.java @@ -0,0 +1,250 @@ +package com.yxtech.ossutil.util; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.Header; +import org.apache.http.HttpStatus; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.entity.mime.HttpMultipartMode; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +/** + * IOSP OSS 文件工具类 + * - OAuth2 client_credentials 鉴权,内存缓存 token,401 自动重试 + * - 共享连接池,复用 TCP 连接 + * - 文件下载/预览,文件类型白名单校验 + */ +@Slf4j +public class XjOssUtil { + + // ---- Setter ---- + // ---- 配置(由 XjOssConfig 注入) ---- + @Setter + private static String gatewayUrl; + @Setter + private static String clientId; + @Setter + private static String clientSecret; + @Setter + private static String organizationId; + @Setter + private static String configCode; + @Setter + private static String directory; + /** 允许访问的文件扩展名,空集合表示不限制 */ + private static Set allowedExts = Collections.emptySet(); + + // ---- token 内存缓存 ---- + private static final AtomicReference cachedToken = new AtomicReference<>(); + /** 过期时间戳(ms),提前 60s 刷新 */ + private static final AtomicLong tokenExpireAt = new AtomicLong(0); + + // ---- 共享连接池 ---- + private static final PoolingHttpClientConnectionManager CM; + /** 普通请求客户端(跟随重定向) */ + private static final CloseableHttpClient HTTP_CLIENT; + /** 取 Location 专用客户端(禁止重定向) */ + private static final CloseableHttpClient NO_REDIRECT_CLIENT; + + private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom() + .setConnectTimeout(10_000) + .setSocketTimeout(10 * 60_000) + .setConnectionRequestTimeout(3_000) + .build(); + + static { + CM = new PoolingHttpClientConnectionManager(60, TimeUnit.SECONDS); + CM.setMaxTotal(200); + CM.setDefaultMaxPerRoute(50); + HTTP_CLIENT = HttpClients.custom() + .setConnectionManager(CM) + .setDefaultRequestConfig(REQUEST_CONFIG) + .build(); + NO_REDIRECT_CLIENT = HttpClientBuilder.create() + .setConnectionManager(CM) + .setDefaultRequestConfig(REQUEST_CONFIG) + .disableRedirectHandling() + .build(); + } + + public static void setFileTypeWhiteList(String v) { + if (v == null || v.isBlank()) { + allowedExts = Collections.emptySet(); + } else { + allowedExts = Arrays.stream(v.split(",")) + .map(s -> s.trim().toLowerCase()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + } + } + + // ---- 鉴权 ---- + + public static String getToken() { + if (System.currentTimeMillis() < tokenExpireAt.get()) { + return cachedToken.get(); + } + return authenticate(); + } + + public static synchronized String authenticate() { + if (System.currentTimeMillis() < tokenExpireAt.get()) { + return cachedToken.get(); + } + HttpPost post = new HttpPost(gatewayUrl + "/oauth/oauth/token"); + post.setConfig(REQUEST_CONFIG); + post.setEntity(MultipartEntityBuilder.create() + .setMode(HttpMultipartMode.BROWSER_COMPATIBLE) + .setCharset(StandardCharsets.UTF_8) + .addTextBody("grant_type", "client_credentials") + .addTextBody("client_id", clientId) + .addTextBody("client_secret", clientSecret) + .build()); + try (CloseableHttpResponse resp = HTTP_CLIENT.execute(post)) { + int status = resp.getStatusLine().getStatusCode(); + if (status != HttpStatus.SC_OK) { + log.error("[XjOssUtil] 鉴权失败,状态码: {}", status); + return null; + } + JSONObject json = JSON.parseObject(EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)); + String token = json.getString("access_token"); + int expiresIn = json.getIntValue("expires_in"); + cachedToken.set(token); + tokenExpireAt.set(System.currentTimeMillis() + Math.max(expiresIn - 60, 30) * 1000L); + log.info("[XjOssUtil] 鉴权成功,有效期 {}s", expiresIn); + return token; + } catch (Exception e) { + log.error("[XjOssUtil] 鉴权异常", e); + return null; + } + } + + // ---- 文件下载 ---- + + /** + * 校验文件类型白名单,不合法时抛出 IllegalArgumentException + * + * @param ext 文件扩展名(小写,不含点) + */ + public static void checkExt(String ext) { + if (!allowedExts.isEmpty() && !allowedExts.contains(ext)) { + throw new IllegalArgumentException("不支持的文件类型: " + ext); + } + } + + /** + * 下载文件,返回文件输入流,调用方负责关闭 + * + * @param fileKey 短文件 key,如 808fec...@aa.png + */ + public static InputStream down(String fileKey) { + // 防御性校验(Controller 已校验,此处作为纵深防御) + if (fileKey == null || fileKey.isBlank() || fileKey.contains("..") || fileKey.contains("\0")) { + log.warn("[XjOssUtil] 拒绝非法 fileKey"); + return null; + } + String locationUrl = getLocationUrl(buildFullFileKey(fileKey), false); + if (locationUrl == null) return null; + // SSRF 防护:只允许 http/https 协议 + if (!locationUrl.startsWith("http://") && !locationUrl.startsWith("https://")) { + log.error("[XjOssUtil] Location URL 协议非法,拒绝请求: {}", locationUrl); + return null; + } + HttpGet get = new HttpGet(locationUrl); + get.setConfig(REQUEST_CONFIG); + try { + CloseableHttpResponse resp = HTTP_CLIENT.execute(get); + int status = resp.getStatusLine().getStatusCode(); + if (status != HttpStatus.SC_OK) { + log.error("[XjOssUtil] 获取文件流失败,状态码: {}", status); + resp.close(); + return null; + } + return new ResponseInputStream(resp); + } catch (Exception e) { + log.error("[XjOssUtil] 文件下载异常", e); + return null; + } + } + + // ---- 私有方法 ---- + + private static String buildFullFileKey(String fileKey) { + String dir = directory.endsWith("/") ? directory : directory + "/"; + return dir + "0/" + fileKey; + } + + private static String getLocationUrl(String fullFileKey, boolean retried) { + try { + URI uri = new URIBuilder(gatewayUrl + "/hfle/v2/" + organizationId + "/files/" + configCode + "/download-by-key") + .addParameter("fileKey", fullFileKey).build(); + HttpGet get = new HttpGet(uri); + get.setConfig(REQUEST_CONFIG); + get.addHeader("Authorization", "Bearer " + getToken()); + try (CloseableHttpResponse resp = NO_REDIRECT_CLIENT.execute(get)) { + int status = resp.getStatusLine().getStatusCode(); + if (status == HttpStatus.SC_UNAUTHORIZED && !retried) { + log.warn("[XjOssUtil] token 过期,重新鉴权后重试"); + tokenExpireAt.set(0); + return getLocationUrl(fullFileKey, true); + } + if (status != HttpStatus.SC_MOVED_TEMPORARILY && status != HttpStatus.SC_SEE_OTHER) { + log.error("[XjOssUtil] download-by-key 非预期状态码: {}", status); + return null; + } + Header location = resp.getFirstHeader("Location"); + if (location == null) { + log.error("[XjOssUtil] download-by-key 未返回 Location"); + return null; + } + return location.getValue(); + } + } catch (Exception e) { + log.error("[XjOssUtil] 获取 Location 异常", e); + return null; + } + } + + // ---- 内部类:包装响应流,close 时归还连接 ---- + + private static class ResponseInputStream extends InputStream { + private final InputStream body; + private final CloseableHttpResponse response; + + ResponseInputStream(CloseableHttpResponse response) throws IOException { + this.response = response; + this.body = response.getEntity().getContent(); + } + + @Override public int read() throws IOException { return body.read(); } + @Override public int read(byte[] b, int off, int len) throws IOException { return body.read(b, off, len); } + + @Override + public void close() throws IOException { + try { body.close(); } finally { response.close(); } + } + } +} diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml new file mode 100644 index 0000000..075133b --- /dev/null +++ b/src/main/resources/app.yml @@ -0,0 +1,26 @@ +server.port: 8080 +solon.config.load: + - "app-env.yml" #可以是环境相关的 +solon.app: + name: 'ossutil' + group: 'yxtech' + +solon.logging: + appender: + console: + level: INFO + file: + level: INFO + +# IOSP OSS 配置 +xj: + oss: +# gateway-url: http://api.iosp.xjyt.petrochina + gateway-url: http://api.iosp.ydpt.tech + client-id: jkglpt + client-secret: jkglpt@12345! + organization-id: 0 + config-code: FILE-OSS-0a85c4dd40ca745a598b872c7974bcaca + directory: scyx/scfz/aygc/jkgl/ + # 允许访问的文件类型,逗号分隔;留空则不限制 + file-type-white-list: json,dat,svg,dwg,pr,pptm,ai,bak,pptx,lic,db,caj,wps,xmind,eps,exe,doc,docx,xls,xlsx,ppt,pps,pdf,xml,webp,MOV,MP4,HEVC,wmv,rmvb,rm,odt,3gp,swf,gz,tgz,log,bz,bz2,tbz,zip,rar,tar,7z,mp3,mid,ogg,mpga,mp4a,wav,wma,avi,dv,mp4,bmp,xlsm,dps,bak,cdr,et,kdh,nh,,nh,mpeg,mpg,mov,wm,flv,mkv,bmp,jpg,jpeg,gif,png,tif,tiff,tga,psd,txt,php,html,htm,js,css,rtf,rtfd,py,java,rb,sh,pl,sql,xml,gdb,gdbx \ No newline at end of file diff --git a/src/test/java/features/HelloTest.java b/src/test/java/features/HelloTest.java new file mode 100644 index 0000000..dde694b --- /dev/null +++ b/src/test/java/features/HelloTest.java @@ -0,0 +1,19 @@ +package features; + +import com.yxtech.ossutil.App; + +import org.junit.jupiter.api.Test; + +import org.noear.solon.test.HttpTester; +import org.noear.solon.test.SolonTest; + +import java.io.IOException; + +@SolonTest(App.class) +public class HelloTest extends HttpTester { + @Test + public void hello() throws IOException { + assert path("/hello?name=world").get().contains("world"); + assert path("/hello?name=solon").get().contains("solon"); + } +} \ No newline at end of file