mirror of
https://github.com/halo-dev/plugin-s3.git
synced 2025-10-14 22:27:11 +00:00
Compare commits
36 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
07127d7e54 | ||
![]() |
565d3cfcaa | ||
![]() |
c0fb2b1017 | ||
![]() |
c79fee9ba1 | ||
![]() |
08d6ff49c8 | ||
![]() |
73112953ba | ||
![]() |
84345ef349 | ||
![]() |
bc4de5fb8e | ||
![]() |
c91f9981df | ||
![]() |
0116ae65d7 | ||
![]() |
0265a71c83 | ||
![]() |
217f1db0de | ||
![]() |
38f2018fb9 | ||
![]() |
faa1ad59bb | ||
![]() |
f784e3789c | ||
![]() |
e2048028f6 | ||
![]() |
91a61fd1d2 | ||
![]() |
1b8bef991e | ||
![]() |
52cfa53d7c | ||
![]() |
c109bbd61f | ||
![]() |
2320800907 | ||
![]() |
00537c164c | ||
![]() |
8be39b9898 | ||
![]() |
022ecea94f | ||
![]() |
b3bdd02e08 | ||
![]() |
5a95b4ced1 | ||
![]() |
88490bb80f | ||
![]() |
5e9b9f803b | ||
![]() |
c635ebede8 | ||
![]() |
459cc1cf94 | ||
![]() |
780258ffc1 | ||
![]() |
c9f13d4b5f | ||
![]() |
72af0fcdac | ||
![]() |
21b752dd25 | ||
![]() |
b5c2c50654 | ||
![]() |
1158ea7ae8 |
59
.github/workflows/workflow.yaml
vendored
59
.github/workflows/workflow.yaml
vendored
@@ -2,10 +2,20 @@ name: Build Plugin JAR File
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**"
|
||||
- "!**.md"
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -20,6 +30,30 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
cache: 'gradle'
|
||||
java-version: 17
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: pnpm/action-setup@v2.0.1
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/widget/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install Frontend Dependencies
|
||||
run: |
|
||||
./gradlew pnpmInstall
|
||||
- name: Build with Gradle
|
||||
run: |
|
||||
# Set the version with tag name when releasing
|
||||
@@ -79,3 +113,26 @@ jobs:
|
||||
name: artifactName,
|
||||
data: await fs.readFile(artifactPathName)
|
||||
});
|
||||
|
||||
app-store-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.event_name == 'release'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Download plugin-s3 jar
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: plugin-s3
|
||||
path: build/libs
|
||||
- name: Sync to Halo App Store
|
||||
uses: halo-sigs/app-store-release-action@main
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
app-id: ${{secrets.APP_ID}}
|
||||
release-id: ${{ github.event.release.id }}
|
||||
assets-dir: "build/libs"
|
||||
halo-username: ${{ secrets.HALO_USERNAME }}
|
||||
halo-password: ${{ secrets.HALO_PASSWORD }}
|
||||
|
76
README.md
76
README.md
@@ -2,6 +2,74 @@
|
||||
|
||||
为 Halo 2.0 提供 S3 协议的对象存储策略,支持阿里云、腾讯云、七牛云等兼容 S3 协议的对象存储服务商
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 下载,目前提供以下两个下载方式:
|
||||
- GitHub Releases:访问 [Releases](https://github.com/halo-dev/plugin-s3/releases) 下载 Assets 中的 JAR 文件。
|
||||
- Halo 应用市场:<https://halo.run/store/apps/app-Qxhpp>
|
||||
2. 安装,插件安装和更新方式可参考:<https://docs.halo.run/user-guide/plugins>
|
||||
3. 进入后台附件管理。
|
||||
4. 点击右上角的存储策略,在存储策略弹框的右上角可新建 S3 Object Storage 存储策略。
|
||||
5. 创建完成之后即可在上传的时候选择新创建的 S3 Object Storage 存储策略。
|
||||
|
||||
## 配置指南
|
||||
|
||||
### Endpoint 访问风格
|
||||
|
||||
请根据下方表格中的兼容访问风格选择,若您的服务商不在表格中,请自行查看服务商的 s3 兼容性文档或自行尝试。
|
||||
|
||||
> 风格说明:<br/>
|
||||
> 当Endpoint填写`s3.example.com`时<br/>
|
||||
> Path Style:SDK将访问`s3.example.com/<bucket-name>/<object-key>`<br/>
|
||||
> Virtual Hosted Style:SDK将访问`<bucket-name>.s3.example.com/<object-key>`
|
||||
|
||||
### Endpoint
|
||||
|
||||
此处统一填写**不带** bucket-name 的 Endpoint,SDK 会自动处理访问风格。
|
||||
|
||||
想了解 s3 协议的 Endpoint 的配置可在服务商的文档中搜索 s3、Endpoint 或访问域名等关键词,一般与服务商自己的 Endpoint 相同。
|
||||
|
||||
> 例如百度云提供 `s3.bj.bcebos.com` 和 `<bucket-name>.s3.bj.bcebos.com` 两种 Endpoint,请填写`s3.bj.bcebos.com`。
|
||||
|
||||
### Access Key & Access Secret
|
||||
|
||||
与服务商自己 API 的 Access Key 和 Access Secret 相同,详情查看对应服务商的文档。
|
||||
|
||||
### Bucket 桶名称
|
||||
|
||||
一般与服务商控制台中的空间名称一致。
|
||||
|
||||
> 注意部分服务商 s3 空间名 ≠ 空间名称,若出现“Access Denied”报错可检查 Bucket 是否正确。
|
||||
>
|
||||
> 可通过 S3Browser 查看桶列表,七牛云也可在“开发者平台-对象存储-空间概览-s3域名”中查看 s3 空间名。
|
||||
|
||||
### Region
|
||||
|
||||
一般留空即可。
|
||||
|
||||
> 若确认过其他配置正确又不能访问,请在服务商的文档中查看并填写英文的 Region,例如 `cn-east-1`。
|
||||
>
|
||||
> Cloudflare 需要填写均为小写字母的 `auto`。
|
||||
|
||||
## 部分对象存储服务商兼容性
|
||||
|
||||
|服务商|文档|兼容访问风格|兼容性|
|
||||
| ----- | ---- | ----- | ----- |
|
||||
|阿里云|<https://help.aliyun.com/document_detail/410748.html>|Virtual Hosted Style|✅|
|
||||
|腾讯云|<https://cloud.tencent.com/document/product/436/41284>|Virtual Hosted Style / <br>Path Style|✅|
|
||||
|七牛云|<https://developer.qiniu.com/kodo/4088/s3-access-domainname>|Virtual Hosted Style / <br>Path Style|✅|
|
||||
|百度云|<https://cloud.baidu.com/doc/BOS/s/xjwvyq9l4>|Virtual Hosted Style / <br>Path Style|✅|
|
||||
|京东云|<https://docs.jdcloud.com/cn/object-storage-service/api/regions-and-endpoints>|Virtual Hosted Style|✅|
|
||||
|金山云|<https://docs.ksyun.com/documents/6761>|Virtual Hosted Style|✅|
|
||||
|青云|<https://docsv3.qingcloud.com/storage/object-storage/s3/intro/>|Virtual Hosted Style / <br>Path Style|✅|
|
||||
|网易数帆|<https://sf.163.com/help/documents/89796157866430464>|Virtual Hosted Style|✅|
|
||||
|Cloudflare|<https://developers.cloudflare.com/r2/data-access/s3-api/>|Virtual Hosted Style / <br>Path Style|✅|
|
||||
| Oracle Cloud |<https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/s3compatibleapi.htm>|Virtual Hosted Style / <br>Path Style|✅|
|
||||
|又拍云|<https://help.upyun.com/knowledge-base/aws-s3%e5%85%bc%e5%ae%b9/>|Virtual Hosted Style / <br>Path Style|✅|
|
||||
|自建minio|\-|Path Style|✅|
|
||||
|华为云|文档未说明是否兼容,工单反馈不保证兼容性,实际测试可以使用|Virtual Hosted Style|❓|
|
||||
|Ucloud|只支持 8MB 大小的分片,本插件暂不支持<br><https://docs.ucloud.cn/ufile/s3/s3_introduction>|\-|❌|
|
||||
|
||||
## 开发环境
|
||||
|
||||
```bash
|
||||
@@ -31,11 +99,3 @@ plugin:
|
||||
```
|
||||
|
||||
构建完成之后,可以在 `build/libs` 目录得到插件的 JAR 包,在 Halo 后台的插件管理上传即可。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 在 [Releases](https://github.com/halo-sigs/plugin-s3/releases) 下载最新的 JAR 文件。
|
||||
2. 在 Halo 后台的插件管理上传 JAR 文件进行安装。
|
||||
3. 进入后台附件管理。
|
||||
4. 点击右上角的存储策略,在存储策略弹框的右上角可新建 S3 Object Storage 存储策略。
|
||||
5. 创建完成之后即可在上传的时候选择新创建的 S3 Object Storage 存储策略。
|
||||
|
70
build.gradle
70
build.gradle
@@ -1,48 +1,62 @@
|
||||
plugins {
|
||||
id "io.github.guqing.plugin-development" version "0.0.6-SNAPSHOT"
|
||||
id 'java'
|
||||
id "com.github.node-gradle.node" version "5.0.0"
|
||||
id "io.freefair.lombok" version "8.0.1"
|
||||
id "run.halo.plugin.devtools" version "0.0.7"
|
||||
}
|
||||
|
||||
group 'run.halo.s3os'
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
|
||||
repositories {
|
||||
maven { url 'https://s01.oss.sonatype.org/content/repositories/releases' }
|
||||
maven { url 'https://repo.spring.io/milestone' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
jar {
|
||||
enabled = true
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
from {
|
||||
configurations.runtimeClasspath.collect {
|
||||
it.isDirectory() ? it : zipTree(it)
|
||||
}
|
||||
}
|
||||
maven { url 'https://s01.oss.sonatype.org/content/repositories/releases' }
|
||||
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' }
|
||||
maven { url 'https://repo.spring.io/milestone' }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly platform("run.halo.dependencies:halo-dependencies:1.0.0")
|
||||
implementation platform('run.halo.tools.platform:plugin:2.10.0-SNAPSHOT')
|
||||
compileOnly 'run.halo.app:api'
|
||||
|
||||
compileOnly files("lib/halo-2.0.0-SNAPSHOT-plain.jar")
|
||||
implementation platform('software.amazon.awssdk:bom:2.19.8')
|
||||
implementation 'software.amazon.awssdk:s3'
|
||||
|
||||
implementation platform('com.amazonaws:aws-java-sdk-bom:1.12.360')
|
||||
implementation 'com.amazonaws:aws-java-sdk-s3'
|
||||
implementation "javax.xml.bind:jaxb-api:2.3.1"
|
||||
implementation "javax.activation:activation:1.1.1"
|
||||
implementation "org.glassfish.jaxb:jaxb-runtime:2.3.3"
|
||||
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.22'
|
||||
|
||||
testImplementation platform("run.halo.dependencies:halo-dependencies:1.0.0")
|
||||
testImplementation files("lib/halo-2.0.0-SNAPSHOT-plain.jar")
|
||||
testImplementation 'run.halo.app:api'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0'
|
||||
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
}
|
||||
|
||||
configurations.runtimeClasspath {
|
||||
exclude group: 'org.reactivestreams', module: 'reactive-streams'
|
||||
}
|
||||
|
||||
|
||||
halo {
|
||||
version = '2.10.0'
|
||||
}
|
||||
|
||||
haloPlugin {
|
||||
watchDomains {
|
||||
consoleSource {
|
||||
files files('console/src/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
node {
|
||||
nodeProjectDir = file("${project.projectDir}/console")
|
||||
}
|
||||
|
||||
task buildFrontend(type: PnpmTask) {
|
||||
args = ['build']
|
||||
}
|
||||
|
||||
build {
|
||||
// build frontend before build
|
||||
tasks.getByName('compileJava').dependsOn('buildFrontend')
|
||||
}
|
||||
|
12
console/.editorconfig
Normal file
12
console/.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = true
|
15
console/.eslintrc.cjs
Normal file
15
console/.eslintrc.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
/* eslint-env node */
|
||||
require("@rushstack/eslint-patch/modern-module-resolution");
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended",
|
||||
"@vue/eslint-config-typescript/recommended",
|
||||
"@vue/eslint-config-prettier",
|
||||
],
|
||||
env: {
|
||||
"vue/setup-compiler-macros": true,
|
||||
},
|
||||
};
|
28
console/.gitignore
vendored
Normal file
28
console/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
6
console/env.d.ts
vendored
Normal file
6
console/env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import Vue from "vue";
|
||||
export default Vue;
|
||||
}
|
45
console/package.json
Normal file
45
console/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@halo-dev/plugin-starter",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 4173",
|
||||
"test:unit": "vitest --environment jsdom",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@halo-dev/components": "^1.5.0",
|
||||
"@halo-dev/console-shared": "^2.8.0",
|
||||
"axios": "^1.4.0",
|
||||
"canvas-confetti": "^1.6.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"vue": "^3.2.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.18",
|
||||
"@rushstack/eslint-patch": "^1.2.0",
|
||||
"@types/canvas-confetti": "^1.6.0",
|
||||
"@types/jsdom": "^20.0.0",
|
||||
"@types/node": "^16.18.0",
|
||||
"@vitejs/plugin-vue": "^3.1.2",
|
||||
"@vitejs/plugin-vue-jsx": "^2.0.1",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.2",
|
||||
"@vue/test-utils": "^2.2.0",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"eslint": "^8.26.0",
|
||||
"eslint-plugin-vue": "^9.6.0",
|
||||
"jsdom": "^19.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.7.1",
|
||||
"sass": "^1.58.0",
|
||||
"typescript": "~4.7.4",
|
||||
"unplugin-icons": "^0.15.2",
|
||||
"vite": "^3.1.8",
|
||||
"vitest": "^0.24.3",
|
||||
"vue-tsc": "^1.0.9"
|
||||
}
|
||||
}
|
3576
console/pnpm-lock.yaml
generated
Normal file
3576
console/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
console/src/assets/logo.svg
Normal file
1
console/src/assets/logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.5 KiB |
1
console/src/controller/index.ts
Normal file
1
console/src/controller/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./s-3-link-controller";
|
@@ -0,0 +1,25 @@
|
||||
import request from "@/utils/request";
|
||||
import { S3ListResult, DeepRequired } from "../../interface";
|
||||
|
||||
/**
|
||||
* /apis/s3os.halo.run/v1alpha1/objects/{policyName}
|
||||
*/
|
||||
export function getApisS3OsHaloRunV1Alpha1ObjectsByPolicyName(params: GetApisS3OsHaloRunV1Alpha1ObjectsByPolicyNameParams) {
|
||||
const paramsInput = {
|
||||
continuationToken: params.continuationToken,
|
||||
continuationObject: params.continuationObject,
|
||||
pageSize: params.pageSize,
|
||||
unlinked: params.unlinked,
|
||||
};
|
||||
return request.get<DeepRequired<S3ListResult>>(`/apis/s3os.halo.run/v1alpha1/objects/${params.policyName}`, {
|
||||
params: paramsInput,
|
||||
});
|
||||
}
|
||||
|
||||
interface GetApisS3OsHaloRunV1Alpha1ObjectsByPolicyNameParams {
|
||||
policyName: any;
|
||||
continuationToken?: any;
|
||||
continuationObject?: any;
|
||||
pageSize: any;
|
||||
unlinked?: any;
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
import request from "@/utils/request";
|
||||
import { DeepRequired } from "../../interface";
|
||||
|
||||
/**
|
||||
* /apis/s3os.halo.run/v1alpha1/policies/s3
|
||||
*/
|
||||
export function getApisS3OsHaloRunV1Alpha1PoliciesS3() {
|
||||
return request.get<DeepRequired<any>>(`/apis/s3os.halo.run/v1alpha1/policies/s3`);
|
||||
}
|
3
console/src/controller/s-3-link-controller/index.ts
Normal file
3
console/src/controller/s-3-link-controller/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./postApisS3OsHaloRunV1Alpha1AttachmentsLink";
|
||||
export * from "./getApisS3OsHaloRunV1Alpha1ObjectsByPolicyName";
|
||||
export * from "./getApisS3OsHaloRunV1Alpha1PoliciesS3";
|
@@ -0,0 +1,9 @@
|
||||
import request from "@/utils/request";
|
||||
import { LinkResult, DeepRequired, LinkRequest } from "../../interface";
|
||||
|
||||
/**
|
||||
* /apis/s3os.halo.run/v1alpha1/attachments/link
|
||||
*/
|
||||
export function postApisS3OsHaloRunV1Alpha1AttachmentsLink(input: LinkRequest) {
|
||||
return request.post<DeepRequired<LinkResult>>(`/apis/s3os.halo.run/v1alpha1/attachments/link`, input);
|
||||
}
|
21
console/src/index.ts
Normal file
21
console/src/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {definePlugin} from "@halo-dev/console-shared";
|
||||
import type {PluginTab} from "@halo-dev/console-shared";
|
||||
import S3Link from "./views/S3Link.vue";
|
||||
import {markRaw} from "vue";
|
||||
|
||||
export default definePlugin({
|
||||
components: {},
|
||||
routes: [],
|
||||
extensionPoints: {
|
||||
"plugin:self:tabs:create": () : PluginTab[] => {
|
||||
return [
|
||||
{
|
||||
id: "s3-link",
|
||||
label: "关联S3文件",
|
||||
component: markRaw(S3Link),
|
||||
permissions: ["plugin:s3os:link"]
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
4
console/src/interface/apiTypes/LinkRequest.ts
Normal file
4
console/src/interface/apiTypes/LinkRequest.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface LinkRequest {
|
||||
objectKeys?: string[];
|
||||
policyName?: string;
|
||||
}
|
5
console/src/interface/apiTypes/LinkResult.ts
Normal file
5
console/src/interface/apiTypes/LinkResult.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LinkResultItem } from "../../interface";
|
||||
|
||||
export interface LinkResult {
|
||||
items?: LinkResultItem[];
|
||||
}
|
5
console/src/interface/apiTypes/LinkResultItem.ts
Normal file
5
console/src/interface/apiTypes/LinkResultItem.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface LinkResultItem {
|
||||
message?: string;
|
||||
objectKey?: string;
|
||||
success?: boolean;
|
||||
}
|
20
console/src/interface/apiTypes/Metadata.ts
Normal file
20
console/src/interface/apiTypes/Metadata.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface Metadata {
|
||||
annotations?: MetadataAnnotations;
|
||||
creationTimestamp?: string;
|
||||
deletionTimestamp?: string;
|
||||
finalizers?: string[];
|
||||
/** The name field will be generated automatically according to the given generateName field */
|
||||
generateName?: string;
|
||||
labels?: MetadataLabels;
|
||||
/** Metadata name */
|
||||
name: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface MetadataAnnotations {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface MetadataLabels {
|
||||
[key: string]: any;
|
||||
}
|
6
console/src/interface/apiTypes/ObjectVo.ts
Normal file
6
console/src/interface/apiTypes/ObjectVo.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ObjectVo {
|
||||
displayName?: string;
|
||||
isLinked?: boolean;
|
||||
key?: string;
|
||||
lastModified?: string;
|
||||
}
|
8
console/src/interface/apiTypes/Policy.ts
Normal file
8
console/src/interface/apiTypes/Policy.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Metadata, PolicySpec } from "../../interface";
|
||||
|
||||
export interface Policy {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: Metadata;
|
||||
spec: PolicySpec;
|
||||
}
|
8
console/src/interface/apiTypes/PolicySpec.ts
Normal file
8
console/src/interface/apiTypes/PolicySpec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface PolicySpec {
|
||||
/** Reference name of ConfigMap extension */
|
||||
configMapName?: string;
|
||||
/** Display name of policy */
|
||||
displayName: string;
|
||||
/** Reference name of PolicyTemplate */
|
||||
templateName: string;
|
||||
}
|
10
console/src/interface/apiTypes/S3ListResult.ts
Normal file
10
console/src/interface/apiTypes/S3ListResult.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ObjectVo } from "../../interface";
|
||||
|
||||
export interface S3ListResult {
|
||||
currentContinuationObject?: string;
|
||||
currentToken?: string;
|
||||
hasMore?: boolean;
|
||||
nextContinuationObject?: string;
|
||||
nextToken?: string;
|
||||
objects?: ObjectVo[];
|
||||
}
|
11
console/src/interface/index.ts
Normal file
11
console/src/interface/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from "./apiTypes/LinkRequest";
|
||||
export * from "./apiTypes/LinkResult";
|
||||
export * from "./apiTypes/LinkResultItem";
|
||||
export * from "./apiTypes/Metadata";
|
||||
export * from "./apiTypes/ObjectVo";
|
||||
export * from "./apiTypes/Policy";
|
||||
export * from "./apiTypes/PolicySpec";
|
||||
export * from "./apiTypes/S3ListResult";
|
||||
|
||||
export type Primitive = undefined | null | boolean | string | number | symbol;
|
||||
export type DeepRequired<T> = T extends Primitive ? T : keyof T extends never ? T : { [K in keyof T]-?: DeepRequired<T[K]> };
|
7
console/src/swagger.config.json
Normal file
7
console/src/swagger.config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"docsUrl": "http://localhost:8090/v3/api-docs/extension-api",
|
||||
"includeTags": ["s-3-link-controller"],
|
||||
"excludeTags": [],
|
||||
"axiosInstanceUrl": "@/utils/request",
|
||||
"prefix": ""
|
||||
}
|
31
console/src/utils/request.ts
Normal file
31
console/src/utils/request.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import axios from "axios";
|
||||
import {Toast} from "@halo-dev/components";
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL;
|
||||
const request = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// 非200状态码就弹窗
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const errorResponse = error.response;
|
||||
if (!errorResponse) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const { status } = errorResponse;
|
||||
if (status !== 200) {
|
||||
Toast.error("status: " + status);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
request.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
|
||||
// TODO 使用halo console 中的axios https://github.com/halo-dev/halo/issues/3979
|
||||
export default request;
|
||||
|
452
console/src/views/S3Link.vue
Normal file
452
console/src/views/S3Link.vue
Normal file
@@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IconRefreshLine,
|
||||
Toast,
|
||||
VButton,
|
||||
VCard,
|
||||
VEmpty,
|
||||
VEntity,
|
||||
VEntityField,
|
||||
VLoading,
|
||||
VModal,
|
||||
VPageHeader,
|
||||
VSpace,
|
||||
VTag,
|
||||
} from "@halo-dev/components";
|
||||
import CarbonFolderDetailsReference from "~icons/carbon/folder-details-reference";
|
||||
import {computed, onMounted, ref, watch} from "vue";
|
||||
import {
|
||||
getApisS3OsHaloRunV1Alpha1ObjectsByPolicyName,
|
||||
getApisS3OsHaloRunV1Alpha1PoliciesS3,
|
||||
postApisS3OsHaloRunV1Alpha1AttachmentsLink,
|
||||
} from "@/controller";
|
||||
import type {ObjectVo, S3ListResult, Policy, LinkResultItem} from "@/interface";
|
||||
|
||||
const selectedFiles = ref<string[]>([]);
|
||||
const policyName = ref<string>("");
|
||||
const page = ref(1);
|
||||
const size = ref(50);
|
||||
const policyOptions = ref<{ label: string; value: string; attrs: any }[]>([{
|
||||
label: "请选择策略",
|
||||
value: "",
|
||||
attrs: {disabled: true}
|
||||
}]);
|
||||
const s3Objects = ref<S3ListResult>({
|
||||
objects: [],
|
||||
hasMore: false,
|
||||
currentToken: "",
|
||||
nextToken: "",
|
||||
currentContinuationObject: "",
|
||||
nextContinuationObject: "",
|
||||
});
|
||||
// view state
|
||||
const isFetching = ref(false);
|
||||
const isShowModal = ref(false);
|
||||
const isLinking = ref(false);
|
||||
const isFetchingPolicies = ref(true);
|
||||
|
||||
const linkTips = ref("");
|
||||
const linkFailedTable = ref<LinkResultItem[]>([]);
|
||||
const linkedStatusItems: { label: string; value?: boolean }[] = [
|
||||
{label: "全部"},
|
||||
{label: "未关联", value: true},
|
||||
];
|
||||
|
||||
// action state
|
||||
const checkedAll = ref(false);
|
||||
const selectedLinkedStatusItem = ref<boolean | undefined>(linkedStatusItems[0].value);
|
||||
|
||||
const emptyTips = computed(() => {
|
||||
if (isFetchingPolicies.value) {
|
||||
return "正在加载策略";
|
||||
} else {
|
||||
if (policyOptions.value.length <= 1) {
|
||||
return "没有可用的策略,请前往【附件】添加S3策略";
|
||||
} else {
|
||||
if (!policyName.value) {
|
||||
return "请在左上方选择策略";
|
||||
} else {
|
||||
return "该策略的 桶/文件夹 下没有文件";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleCheckAllChange = (e: Event) => {
|
||||
const {checked} = e.target as HTMLInputElement;
|
||||
|
||||
if (checked) {
|
||||
selectedFiles.value =
|
||||
s3Objects.value.objects?.filter(file => !file.isLinked).map((file) => {
|
||||
return file.key || "";
|
||||
}) || [];
|
||||
} else {
|
||||
selectedFiles.value.length = 0;
|
||||
checkedAll.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const fetchPolicies = async () => {
|
||||
try {
|
||||
const policiesData = await getApisS3OsHaloRunV1Alpha1PoliciesS3();
|
||||
if (policiesData.status == 200) {
|
||||
policyOptions.value = [{
|
||||
label: "请选择策略",
|
||||
value: "",
|
||||
attrs: {disabled: true}
|
||||
}];
|
||||
policiesData.data.forEach((policy: Policy) => {
|
||||
policyOptions.value.push({
|
||||
label: policy.spec.displayName,
|
||||
value: policy.metadata.name,
|
||||
attrs: {}
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
isFetchingPolicies.value = false;
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
fetchPolicies();
|
||||
});
|
||||
|
||||
watch(selectedFiles, (newValue) => {
|
||||
checkedAll.value = s3Objects.value.objects?.filter(file => !file.isLinked)
|
||||
.filter(file => !newValue.includes(file.key || "")).length == 0
|
||||
&& s3Objects.value.objects?.length != 0;
|
||||
});
|
||||
|
||||
watch(selectedLinkedStatusItem, () => {
|
||||
handleFirstPage();
|
||||
});
|
||||
|
||||
const changeNextTokenAndObject = () => {
|
||||
s3Objects.value.currentToken = s3Objects.value.nextToken;
|
||||
s3Objects.value.currentContinuationObject = s3Objects.value.nextContinuationObject;
|
||||
s3Objects.value.nextToken = "";
|
||||
s3Objects.value.nextContinuationObject = "";
|
||||
};
|
||||
|
||||
const clearTokenAndObject = () => {
|
||||
s3Objects.value.currentToken = "";
|
||||
s3Objects.value.currentContinuationObject = "";
|
||||
s3Objects.value.nextToken = "";
|
||||
s3Objects.value.nextContinuationObject = "";
|
||||
};
|
||||
|
||||
const fetchObjects = async () => {
|
||||
if (!policyName.value) {
|
||||
return;
|
||||
}
|
||||
isFetching.value = true;
|
||||
s3Objects.value.objects = [];
|
||||
try {
|
||||
const objectsData = await getApisS3OsHaloRunV1Alpha1ObjectsByPolicyName({
|
||||
policyName: policyName.value,
|
||||
pageSize: size.value,
|
||||
continuationToken: s3Objects.value.currentToken,
|
||||
continuationObject: s3Objects.value.currentContinuationObject,
|
||||
unlinked: selectedLinkedStatusItem.value,
|
||||
});
|
||||
if (objectsData.status == 200) {
|
||||
s3Objects.value = objectsData.data;
|
||||
|
||||
if (s3Objects.value.objects?.length == 0 && s3Objects.value.hasMore && s3Objects.value.nextToken) {
|
||||
changeNextTokenAndObject();
|
||||
await fetchObjects();
|
||||
} else if (s3Objects.value.objects?.length == 0 && !s3Objects.value.hasMore && page.value > 1) {
|
||||
page.value = 1;
|
||||
clearTokenAndObject();
|
||||
await fetchObjects();
|
||||
Toast.warning("最后一页为空,已返回第一页");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
selectedFiles.value.length = 0;
|
||||
checkedAll.value = false;
|
||||
isFetching.value = false;
|
||||
};
|
||||
|
||||
const checkSelection = (file: ObjectVo) => {
|
||||
return selectedFiles.value.includes(file.key || "");
|
||||
};
|
||||
|
||||
const handleLink = async () => {
|
||||
isLinking.value = true;
|
||||
isShowModal.value = true;
|
||||
linkTips.value = `正在关联${selectedFiles.value.length}个文件`;
|
||||
linkFailedTable.value = [];
|
||||
const linkResult = await postApisS3OsHaloRunV1Alpha1AttachmentsLink({
|
||||
policyName: policyName.value,
|
||||
objectKeys: selectedFiles.value
|
||||
});
|
||||
|
||||
const successCount = linkResult.data.items.filter(item => item.success).length;
|
||||
const failedCount = linkResult.data.items.filter(item => !item.success).length;
|
||||
linkTips.value = `关联成功${successCount}个文件,关联失败${failedCount}个文件`;
|
||||
|
||||
if (failedCount > 0) {
|
||||
linkFailedTable.value = linkResult.data.items.filter(item => !item.success);
|
||||
}
|
||||
isLinking.value = false;
|
||||
};
|
||||
|
||||
const selectOneAndLink = (file: ObjectVo) => {
|
||||
selectedFiles.value = [file.key || ""];
|
||||
handleLink();
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (!policyName.value) {
|
||||
return;
|
||||
}
|
||||
if (s3Objects.value.hasMore) {
|
||||
isFetching.value = true;
|
||||
page.value += 1;
|
||||
changeNextTokenAndObject();
|
||||
fetchObjects();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirstPage = () => {
|
||||
if (!policyName.value) {
|
||||
return;
|
||||
}
|
||||
isFetching.value = true;
|
||||
page.value = 1;
|
||||
clearTokenAndObject();
|
||||
fetchObjects();
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
isShowModal.value = false;
|
||||
fetchObjects();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPageHeader title="关联S3文件(Beta)">
|
||||
<template #icon>
|
||||
<CarbonFolderDetailsReference class="mr-2 self-center"/>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
<div class="m-0 md:m-4">
|
||||
<VCard :body-class="['!p-0']">
|
||||
<template #header>
|
||||
<div class="block w-full bg-gray-50 px-4 py-3">
|
||||
<div
|
||||
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div class="hidden items-center sm:flex">
|
||||
<input
|
||||
v-model="checkedAll"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||
type="checkbox"
|
||||
@change="handleCheckAllChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full flex-1 items-center sm:w-auto">
|
||||
<div
|
||||
v-if="!selectedFiles.length"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
策略:
|
||||
<FormKit
|
||||
id="policyChoose"
|
||||
outer-class="!p-0 w-48"
|
||||
v-model="policyName"
|
||||
name="policyName"
|
||||
type="select"
|
||||
:options="policyOptions"
|
||||
@change="fetchObjects()"
|
||||
></FormKit>
|
||||
</div>
|
||||
<VSpace v-else>
|
||||
<VButton type="primary" @click="handleLink">
|
||||
关联
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</div>
|
||||
<VSpace spacing="lg" class="flex-wrap">
|
||||
<FilterCleanButton
|
||||
v-if="selectedLinkedStatusItem != linkedStatusItems[0].value"
|
||||
@click="selectedLinkedStatusItem = linkedStatusItems[0].value"
|
||||
/>
|
||||
<FilterDropdown
|
||||
v-model="selectedLinkedStatusItem"
|
||||
:label="$t('core.common.filters.labels.status')"
|
||||
:items="linkedStatusItems"
|
||||
/>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<div
|
||||
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
|
||||
@click="fetchObjects()"
|
||||
>
|
||||
<IconRefreshLine
|
||||
v-tooltip="$t('core.common.buttons.refresh')"
|
||||
:class="{
|
||||
'animate-spin text-gray-900': isFetching,
|
||||
}"
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VSpace>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VLoading v-if="isFetching"/>
|
||||
|
||||
<Transition v-else-if="!s3Objects.objects?.length" appear name="fade">
|
||||
<VEmpty
|
||||
message="空空如也"
|
||||
:title="emptyTips"
|
||||
>
|
||||
</VEmpty>
|
||||
</Transition>
|
||||
|
||||
<Transition v-else appear name="fade">
|
||||
<ul
|
||||
class="box-border h-full w-full divide-y divide-gray-100"
|
||||
role="list"
|
||||
>
|
||||
<li v-for="(file, index) in s3Objects.objects" :key="index">
|
||||
<VEntity :is-selected="checkSelection(file)">
|
||||
<template
|
||||
#checkbox
|
||||
>
|
||||
<input
|
||||
v-model="selectedFiles"
|
||||
:value="file.key || ''"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||
name="post-checkbox"
|
||||
:disabled="file.isLinked"
|
||||
type="checkbox"
|
||||
/>
|
||||
</template>
|
||||
<template #start>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<AttachmentFileTypeIcon
|
||||
:display-ext="false"
|
||||
:file-name="file.displayName || ''"
|
||||
:width="8"
|
||||
:height="8"
|
||||
/>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField
|
||||
:title="file.displayName || ''"
|
||||
:description="file.key || ''"
|
||||
/>
|
||||
</template>
|
||||
<template #end>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<VTag :theme="file.isLinked ? 'default':'primary'">
|
||||
{{
|
||||
file.isLinked ? '已关联' : '未关联'
|
||||
}}
|
||||
</VTag>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<VButton
|
||||
:disabled="file.isLinked || false"
|
||||
@click="selectOneAndLink(file)"
|
||||
>
|
||||
关联
|
||||
</VButton>
|
||||
</template>
|
||||
</VEntityField>
|
||||
</template>
|
||||
</VEntity>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
|
||||
<template #footer>
|
||||
<div class="bg-white sm:flex sm:items-center justify-between">
|
||||
<div class="inline-flex items-center gap-5">
|
||||
<span class="text-xs text-gray-500 hidden md:flex">共 {{ s3Objects.objects?.length }} 项数据</span>
|
||||
<span class="text-xs text-gray-500 hidden md:flex">已自动过滤文件夹对象,页面实际显示数量少为正常现象</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-5">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<VButton size="small" @click="handleFirstPage" :disabled="!policyName">返回第一页</VButton>
|
||||
|
||||
<span class="text-sm text-gray-500">第 {{ page }} 页</span>
|
||||
|
||||
<VButton size="small" @click="handleNextPage" :disabled="!s3Objects.hasMore || isFetching || !policyName">
|
||||
下一页
|
||||
</VButton>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<select
|
||||
v-model="size"
|
||||
class="h-8 border outline-none rounded-base px-2 text-gray-800 text-sm border-gray-300"
|
||||
@change="handleFirstPage"
|
||||
>
|
||||
<option
|
||||
v-for="(sizeOption, index) in [20, 50, 100, 200]"
|
||||
:key="index"
|
||||
:value="sizeOption"
|
||||
>
|
||||
{{ sizeOption }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-sm text-gray-500">条/页</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VCard>
|
||||
</div>
|
||||
<VModal
|
||||
:visible="isShowModal"
|
||||
:fullscreen="false"
|
||||
:title="'关联结果'"
|
||||
:width="500"
|
||||
:mount-to-body="true"
|
||||
@close="handleModalClose"
|
||||
>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton
|
||||
:loading="isLinking"
|
||||
type="primary"
|
||||
@click="handleModalClose"
|
||||
>
|
||||
确定
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
<div class="flex flex-col">
|
||||
{{ linkTips }}
|
||||
<table v-if="linkFailedTable.length != 0">
|
||||
<tr>
|
||||
<th class="border border-black font-normal">失败对象</th>
|
||||
<th class="border border-black font-normal">失败原因</th>
|
||||
</tr>
|
||||
<tr v-for="failedInfo in linkFailedTable" :key="failedInfo.objectKey">
|
||||
<th class="border border-black font-normal">{{failedInfo.objectKey}}</th>
|
||||
<th class="border border-black font-normal">{{failedInfo.message}}</th>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</VModal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
13
console/tsconfig.app.json
Normal file
13
console/tsconfig.app.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["./env.d.ts", "./src/**/*", "./src/**/*.vue"],
|
||||
"exclude": ["./src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["unplugin-icons/types/vue"]
|
||||
}
|
||||
}
|
8
console/tsconfig.config.json
Normal file
8
console/tsconfig.config.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
14
console/tsconfig.json
Normal file
14
console/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.config.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
9
console/tsconfig.vitest.json
Normal file
9
console/tsconfig.vitest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
47
console/vite.config.ts
Normal file
47
console/vite.config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { fileURLToPath, URL } from "url";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import Vue from "@vitejs/plugin-vue";
|
||||
import VueJsx from "@vitejs/plugin-vue-jsx";
|
||||
import Icons from "unplugin-icons/vite";
|
||||
|
||||
const pluginEntryName = "PluginS3ObjectStorage";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [Vue(), VueJsx(), Icons({ compiler: "vue3" })],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: fileURLToPath(
|
||||
new URL("../src/main/resources/console", import.meta.url)
|
||||
),
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
name: pluginEntryName,
|
||||
formats: ["iife"],
|
||||
fileName: () => "main.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"vue",
|
||||
"@halo-dev/console-shared",
|
||||
"@halo-dev/components",
|
||||
"vue-router",
|
||||
],
|
||||
output: {
|
||||
globals: {
|
||||
vue: "Vue",
|
||||
"vue-router": "VueRouter",
|
||||
"@halo-dev/components": "HaloComponents",
|
||||
"@halo-dev/console-shared": "HaloConsoleShared",
|
||||
},
|
||||
extend: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
@@ -1 +1 @@
|
||||
version=1.2.1-SNAPSHOT
|
||||
version=1.6.1-SNAPSHOT
|
||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
Binary file not shown.
108
src/main/java/run/halo/s3os/FileNameUtils.java
Normal file
108
src/main/java/run/halo/s3os/FileNameUtils.java
Normal file
@@ -0,0 +1,108 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import com.google.common.io.Files;
|
||||
import org.apache.commons.lang3.RandomStringUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class FileNameUtils {
|
||||
|
||||
private FileNameUtils() {
|
||||
}
|
||||
|
||||
public static String removeFileExtension(String filename, boolean removeAllExtensions) {
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
return filename;
|
||||
}
|
||||
var extPattern = "(?<!^)[.]" + (removeAllExtensions ? ".*" : "[^.]*$");
|
||||
return filename.replaceAll(extPattern, "");
|
||||
}
|
||||
|
||||
public static String getRandomFilename(String filename, Integer length, String mode) {
|
||||
return switch (mode) {
|
||||
// case "none" -> filename;
|
||||
case "withString" -> randomFilenameWithString(filename, length);
|
||||
case "dateWithString" -> randomDateWithString(filename, length);
|
||||
case "datetimeWithString" -> randomDatetimeWithString(filename, length);
|
||||
case "string" -> randomString(filename, length);
|
||||
case "uuid" -> randomUuid(filename);
|
||||
default -> filename;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Append random string after file name.
|
||||
* <pre>
|
||||
* Case 1: halo.run -> halo-xyz.run
|
||||
* Case 2: .run -> xyz.run
|
||||
* Case 3: halo -> halo-xyz
|
||||
* </pre>
|
||||
*
|
||||
* @param filename is name of file.
|
||||
* @param length is for generating random string with specific length.
|
||||
* @return File name with random string.
|
||||
*/
|
||||
public static String randomFilenameWithString(String filename, Integer length) {
|
||||
String random = RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||
return randomFilename(filename, random, true);
|
||||
}
|
||||
|
||||
private static String randomDateWithString(String filename, Integer length) {
|
||||
String random = LocalDate.now() + "-" + RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||
return randomFilename(filename, random, false);
|
||||
}
|
||||
|
||||
private static String randomDatetimeWithString(String filename, Integer length) {
|
||||
String random = LocalDateTime.now() + "-" + RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||
return randomFilename(filename, random, false);
|
||||
}
|
||||
|
||||
private static String randomString(String filename, Integer length) {
|
||||
String random = RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||
return randomFilename(filename, random, false);
|
||||
}
|
||||
|
||||
private static String randomUuid(String filename) {
|
||||
String random = UUID.randomUUID().toString().toUpperCase();
|
||||
return randomFilename(filename, random, false);
|
||||
}
|
||||
|
||||
private static String randomFilename(String filename, String random, Boolean needOriginalName) {
|
||||
String nameWithoutExtension = Files.getNameWithoutExtension(filename);
|
||||
String extension = Files.getFileExtension(filename);
|
||||
boolean nameIsEmpty = StringUtils.isBlank(nameWithoutExtension);
|
||||
boolean extensionIsEmpty = StringUtils.isBlank(extension);
|
||||
if (needOriginalName) {
|
||||
if (nameIsEmpty) {
|
||||
return random + "." + extension;
|
||||
}
|
||||
if (extensionIsEmpty) {
|
||||
return nameWithoutExtension + "-" + random;
|
||||
}
|
||||
return nameWithoutExtension + "-" + random + "." + extension;
|
||||
}
|
||||
else {
|
||||
if (extensionIsEmpty) {
|
||||
return random;
|
||||
}
|
||||
return random + "." + extension;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the file name from an Amazon S3 object key.
|
||||
*
|
||||
* @param objectKey The Amazon S3 object key from which to extract the file name.
|
||||
* @return The extracted file name.
|
||||
*/
|
||||
public static String extractFileNameFromS3Key(String objectKey) {
|
||||
int lastSlashIndex = objectKey.lastIndexOf("/");
|
||||
if (lastSlashIndex >= 0 && lastSlashIndex < objectKey.length() - 1) {
|
||||
return objectKey.substring(lastSlashIndex + 1);
|
||||
}
|
||||
return objectKey;
|
||||
}
|
||||
}
|
23
src/main/java/run/halo/s3os/FilePathUtils.java
Normal file
23
src/main/java/run/halo/s3os/FilePathUtils.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public class FilePathUtils {
|
||||
private FilePathUtils() {
|
||||
|
||||
}
|
||||
|
||||
public static String getFilePathByPlaceholder(String filename) {
|
||||
LocalDate localDate = LocalDate.now();
|
||||
return StringUtils.replaceEach(filename,
|
||||
new String[] {"${year}","${month}","${day}"},
|
||||
new String[] {
|
||||
String.valueOf(localDate.getYear()),
|
||||
String.valueOf(localDate.getMonthValue()),
|
||||
String.valueOf(localDate.getDayOfMonth())
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
13
src/main/java/run/halo/s3os/LinkRequest.java
Normal file
13
src/main/java/run/halo/s3os/LinkRequest.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@RequiredArgsConstructor
|
||||
public class LinkRequest {
|
||||
private String policyName;
|
||||
private List<String> objectKeys;
|
||||
}
|
24
src/main/java/run/halo/s3os/LinkResult.java
Normal file
24
src/main/java/run/halo/s3os/LinkResult.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class LinkResult {
|
||||
|
||||
private List<LinkResultItem> items;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public static class LinkResultItem {
|
||||
private String objectKey;
|
||||
|
||||
private Boolean success;
|
||||
|
||||
private String message;
|
||||
}
|
||||
}
|
85
src/main/java/run/halo/s3os/S3LinkController.java
Normal file
85
src/main/java/run/halo/s3os/S3LinkController.java
Normal file
@@ -0,0 +1,85 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.attachment.Policy;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.ApiVersion;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@ApiVersion("s3os.halo.run/v1alpha1")
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class S3LinkController {
|
||||
private final S3LinkService s3LinkService;
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
/**
|
||||
* Map of linking file, used as a lock, key is policyName/objectKey, value is policyName/objectKey.
|
||||
*/
|
||||
private final Map<String, Object> linkingFile = new ConcurrentHashMap<>();
|
||||
|
||||
@GetMapping("/policies/s3")
|
||||
public Flux<Policy> listS3Policies() {
|
||||
return s3LinkService.listS3Policies();
|
||||
}
|
||||
|
||||
@GetMapping("/objects/{policyName}")
|
||||
public Mono<S3ListResult> listObjects(@PathVariable String policyName,
|
||||
@RequestParam(name = "continuationToken", required = false) String continuationToken,
|
||||
@RequestParam(name = "continuationObject", required = false) String continuationObject,
|
||||
@RequestParam(name = "pageSize") Integer pageSize,
|
||||
@RequestParam(name = "unlinked", required = false, defaultValue = "false")
|
||||
Boolean unlinked) {
|
||||
if (unlinked) {
|
||||
return s3LinkService.listObjectsUnlinked(policyName, continuationToken,
|
||||
continuationObject, pageSize);
|
||||
} else {
|
||||
return s3LinkService.listObjects(policyName, continuationToken, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/attachments/link")
|
||||
public Mono<LinkResult> addAttachmentRecord(@RequestBody LinkRequest linkRequest) {
|
||||
return Flux.fromIterable(linkRequest.getObjectKeys())
|
||||
.filter(objectKey -> linkingFile.put(linkRequest.getPolicyName() + "/" + objectKey,
|
||||
linkRequest.getPolicyName() + "/" + objectKey) == null)
|
||||
.collectList()
|
||||
.flatMap(operableObjectKeys -> client.list(Attachment.class,
|
||||
attachment -> Objects.equals(attachment.getSpec().getPolicyName(),
|
||||
linkRequest.getPolicyName())
|
||||
&& StringUtils.isNotEmpty(attachment.getMetadata().getAnnotations()
|
||||
.get(S3OsAttachmentHandler.OBJECT_KEY))
|
||||
&& linkRequest.getObjectKeys().contains(attachment.getMetadata()
|
||||
.getAnnotations().get(S3OsAttachmentHandler.OBJECT_KEY)),
|
||||
null)
|
||||
.collectList()
|
||||
.flatMap(existingAttachments -> Flux.fromIterable(linkRequest.getObjectKeys())
|
||||
.flatMap((objectKey) -> {
|
||||
if (operableObjectKeys.contains(objectKey) && existingAttachments.stream()
|
||||
.noneMatch(attachment -> Objects.equals(
|
||||
attachment.getMetadata().getAnnotations().get(
|
||||
S3OsAttachmentHandler.OBJECT_KEY), objectKey))) {
|
||||
return s3LinkService
|
||||
.addAttachmentRecord(linkRequest.getPolicyName(), objectKey)
|
||||
.onErrorResume((throwable) -> Mono.just(
|
||||
new LinkResult.LinkResultItem(objectKey, false,
|
||||
throwable.getMessage())));
|
||||
} else {
|
||||
return Mono.just(new LinkResult.LinkResultItem(objectKey, false,
|
||||
"附件库中已存在该对象"));
|
||||
}
|
||||
})
|
||||
.doOnNext(linkResultItem -> linkingFile.remove(
|
||||
linkRequest.getPolicyName() + "/" + linkResultItem.getObjectKey()))
|
||||
.collectList()
|
||||
.map(LinkResult::new)));
|
||||
}
|
||||
}
|
18
src/main/java/run/halo/s3os/S3LinkService.java
Normal file
18
src/main/java/run/halo/s3os/S3LinkService.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.attachment.Policy;
|
||||
|
||||
public interface S3LinkService {
|
||||
Flux<Policy> listS3Policies();
|
||||
|
||||
Mono<S3ListResult> listObjects(String policyName, String continuationToken,
|
||||
Integer pageSize);
|
||||
|
||||
Mono<LinkResult.LinkResultItem> addAttachmentRecord(String policyName, String objectKey);
|
||||
|
||||
Mono<S3ListResult> listObjectsUnlinked(String policyName, String continuationToken,
|
||||
String continuationObject, Integer pageSize);
|
||||
}
|
215
src/main/java/run/halo/s3os/S3LinkServiceImpl.java
Normal file
215
src/main/java/run/halo/s3os/S3LinkServiceImpl.java
Normal file
@@ -0,0 +1,215 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.attachment.Constant;
|
||||
import run.halo.app.core.extension.attachment.Policy;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static run.halo.s3os.S3OsAttachmentHandler.OBJECT_KEY;
|
||||
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class S3LinkServiceImpl implements S3LinkService {
|
||||
private final ReactiveExtensionClient client;
|
||||
private final S3OsAttachmentHandler handler;
|
||||
|
||||
|
||||
@Override
|
||||
public Flux<Policy> listS3Policies() {
|
||||
return client.list(Policy.class, (policy) -> "s3os".equals(
|
||||
policy.getSpec().getTemplateName()), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<S3ListResult> listObjects(String policyName, String continuationToken,
|
||||
Integer pageSize) {
|
||||
return client.fetch(Policy.class, policyName)
|
||||
.flatMap((policy) -> {
|
||||
var configMapName = policy.getSpec().getConfigMapName();
|
||||
return client.fetch(ConfigMap.class, configMapName);
|
||||
})
|
||||
.flatMap((configMap) -> {
|
||||
var properties = handler.getProperties(configMap);
|
||||
var finalLocation = FilePathUtils.getFilePathByPlaceholder(properties.getLocation());
|
||||
return Mono.using(() -> handler.buildS3Client(properties),
|
||||
(s3Client) -> Mono.fromCallable(
|
||||
() -> s3Client.listObjectsV2(ListObjectsV2Request.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.prefix(StringUtils.isNotEmpty(finalLocation)
|
||||
? finalLocation + "/" : null)
|
||||
.delimiter("/")
|
||||
.maxKeys(pageSize)
|
||||
.continuationToken(StringUtils.isNotEmpty(continuationToken)
|
||||
? continuationToken : null)
|
||||
.build())).subscribeOn(Schedulers.boundedElastic()),
|
||||
S3Client::close)
|
||||
.flatMap(listObjectsV2Response -> {
|
||||
List<S3Object> contents = listObjectsV2Response.contents();
|
||||
var objectVos = contents
|
||||
.stream().map(S3ListResult.ObjectVo::fromS3Object)
|
||||
.filter(objectVo -> !objectVo.getKey().endsWith("/"))
|
||||
.collect(Collectors.toMap(S3ListResult.ObjectVo::getKey, o -> o));
|
||||
return client.list(Attachment.class,
|
||||
attachment -> policyName.equals(
|
||||
attachment.getSpec().getPolicyName()), null)
|
||||
.doOnNext(attachment -> {
|
||||
S3ListResult.ObjectVo objectVo =
|
||||
objectVos.get(attachment.getMetadata().getAnnotations()
|
||||
.getOrDefault(OBJECT_KEY, ""));
|
||||
if (objectVo != null) {
|
||||
objectVo.setIsLinked(true);
|
||||
}
|
||||
})
|
||||
.then()
|
||||
.thenReturn(new S3ListResult(new ArrayList<>(objectVos.values()),
|
||||
listObjectsV2Response.continuationToken(),
|
||||
null, null,
|
||||
listObjectsV2Response.nextContinuationToken(),
|
||||
listObjectsV2Response.isTruncated()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<S3ListResult> listObjectsUnlinked(String policyName, String continuationToken,
|
||||
String continuationObject, Integer pageSize) {
|
||||
// TODO 优化成查一次数据库
|
||||
return Mono.defer(() -> {
|
||||
List<S3ListResult.ObjectVo> s3Objects = new ArrayList<>();
|
||||
AtomicBoolean continuationObjectMatched = new AtomicBoolean(false);
|
||||
AtomicReference<String> currToken = new AtomicReference<>(continuationToken);
|
||||
|
||||
return Flux.defer(() -> Flux.just(
|
||||
new TokenState(null, currToken.get() == null ? "" : currToken.get())))
|
||||
.flatMap(tokenState -> listObjects(policyName, tokenState.nextToken, pageSize))
|
||||
.flatMap(s3ListResult -> {
|
||||
var filteredObjects = s3ListResult.getObjects();
|
||||
if (!continuationObjectMatched.get()) {
|
||||
// 判断s3ListResult.getObjects()里是否有continuationObject
|
||||
var continuationObjectVo = s3ListResult.getObjects().stream()
|
||||
.filter(objectVo -> objectVo.getKey().equals(continuationObject))
|
||||
.findFirst();
|
||||
if (continuationObjectVo.isPresent()) {
|
||||
s3Objects.clear();
|
||||
// 删除continuationObject及之前的所有对象
|
||||
filteredObjects = s3ListResult.getObjects().stream()
|
||||
.dropWhile(objectVo -> !objectVo.getKey()
|
||||
.equals(continuationObject))
|
||||
.skip(1)
|
||||
.toList();
|
||||
continuationObjectMatched.set(true);
|
||||
}
|
||||
}
|
||||
filteredObjects = filteredObjects.stream()
|
||||
.filter(objectVo -> !objectVo.getIsLinked())
|
||||
.toList();
|
||||
s3Objects.addAll(filteredObjects);
|
||||
currToken.set(s3ListResult.getNextToken());
|
||||
return Mono.just(new TokenState(s3ListResult.getCurrentToken(),
|
||||
s3ListResult.getNextToken()));
|
||||
})
|
||||
.repeat()
|
||||
.takeUntil(
|
||||
tokenState -> tokenState.nextToken() == null || s3Objects.size() >= pageSize)
|
||||
.last()
|
||||
.map(tokenState -> {
|
||||
var limitedObjects = s3Objects.stream().limit(pageSize).toList();
|
||||
return new S3ListResult(limitedObjects, continuationToken, continuationObject,
|
||||
!limitedObjects.isEmpty() ? limitedObjects.get(limitedObjects.size() - 1)
|
||||
.getKey() : null, tokenState.currToken,
|
||||
limitedObjects.size() == pageSize);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
record TokenState(String currToken, String nextToken) {
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<LinkResult.LinkResultItem> addAttachmentRecord(String policyName,
|
||||
String objectKey) {
|
||||
return authenticationConsumer(authentication -> client.fetch(Policy.class, policyName)
|
||||
.flatMap((policy) -> {
|
||||
var configMapName = policy.getSpec().getConfigMapName();
|
||||
return client.fetch(ConfigMap.class, configMapName);
|
||||
})
|
||||
.flatMap(configMap -> {
|
||||
var properties = handler.getProperties(configMap);
|
||||
return Mono.using(() -> handler.buildS3Client(properties),
|
||||
(s3Client) -> Mono.fromCallable(
|
||||
() -> s3Client.headObject(
|
||||
HeadObjectRequest.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.key(objectKey)
|
||||
.build()))
|
||||
.subscribeOn(Schedulers.boundedElastic()),
|
||||
S3Client::close)
|
||||
.map(headObjectResponse -> {
|
||||
var objectDetail = new S3OsAttachmentHandler.ObjectDetail(
|
||||
new S3OsAttachmentHandler.UploadState(properties,
|
||||
FileNameUtils.extractFileNameFromS3Key(objectKey), false),
|
||||
headObjectResponse);
|
||||
return handler.buildAttachment(properties, objectDetail);
|
||||
})
|
||||
.doOnNext(attachment -> {
|
||||
var spec = attachment.getSpec();
|
||||
if (spec == null) {
|
||||
spec = new Attachment.AttachmentSpec();
|
||||
attachment.setSpec(spec);
|
||||
}
|
||||
spec.setOwnerName(authentication.getName());
|
||||
spec.setPolicyName(policyName);
|
||||
})
|
||||
.flatMap(client::create)
|
||||
.thenReturn(new LinkResult.LinkResultItem(objectKey, true, null));
|
||||
}))
|
||||
.onErrorResume(throwable ->
|
||||
Mono.just(new LinkResult.LinkResultItem(objectKey, false, throwable.getMessage())));
|
||||
}
|
||||
|
||||
private <T> Mono<T> authenticationConsumer(Function<Authentication, Mono<T>> func) {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.switchIfEmpty(Mono.error(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED,
|
||||
"Authentication required.")))
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.flatMap(func);
|
||||
}
|
||||
|
||||
}
|
38
src/main/java/run/halo/s3os/S3ListResult.java
Normal file
38
src/main/java/run/halo/s3os/S3ListResult.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class S3ListResult {
|
||||
private List<ObjectVo> objects;
|
||||
private String currentToken;
|
||||
private String currentContinuationObject;
|
||||
private String nextContinuationObject;
|
||||
private String nextToken;
|
||||
private Boolean hasMore;
|
||||
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public static class ObjectVo {
|
||||
private String key;
|
||||
|
||||
private Instant lastModified;
|
||||
|
||||
private Boolean isLinked;
|
||||
|
||||
private String displayName;
|
||||
|
||||
public static ObjectVo fromS3Object(S3Object s3Object) {
|
||||
final var key = s3Object.key();
|
||||
final var displayName = key.substring(key.lastIndexOf("/") + 1);
|
||||
return new ObjectVo(key, s3Object.lastModified(), false, displayName);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,25 +1,35 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import com.amazonaws.AmazonServiceException;
|
||||
import com.amazonaws.SdkClientException;
|
||||
import com.amazonaws.auth.AWSStaticCredentialsProvider;
|
||||
import com.amazonaws.auth.BasicAWSCredentials;
|
||||
import com.amazonaws.client.builder.AwsClientBuilder;
|
||||
import com.amazonaws.services.s3.AmazonS3;
|
||||
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata;
|
||||
import com.amazonaws.services.s3.model.PutObjectRequest;
|
||||
import com.amazonaws.services.s3.model.PutObjectResult;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.pf4j.Extension;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.web.server.ServerErrorException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
import reactor.core.Exceptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import reactor.util.context.Context;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec;
|
||||
import run.halo.app.core.extension.attachment.Constant;
|
||||
@@ -28,76 +38,149 @@ import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.awscore.presigner.SdkPresigner;
|
||||
import software.amazon.awssdk.core.SdkResponse;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.http.SdkHttpResponse;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
|
||||
import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
|
||||
import software.amazon.awssdk.services.s3.model.CompletedPart;
|
||||
import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.UploadPartRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
import software.amazon.awssdk.utils.SdkAutoCloseable;
|
||||
|
||||
@Slf4j
|
||||
@Extension
|
||||
public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
|
||||
private static final String OBJECT_KEY = "s3os.plugin.halo.run/object-key";
|
||||
public static final String OBJECT_KEY = "s3os.plugin.halo.run/object-key";
|
||||
public static final String URL_SUFFIX_ANNO_KEY = "s3os.plugin.halo.run/url-suffix";
|
||||
public static final int MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Map to store uploading file, used as a lock, key is bucket/objectKey, value is bucket/objectKey.
|
||||
*/
|
||||
private final Map<String, Object> uploadingFile = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Mono<Attachment> upload(UploadContext uploadContext) {
|
||||
return Mono.just(uploadContext).filter(context -> this.shouldHandle(context.policy()))
|
||||
.flatMap(context -> {
|
||||
final var properties = getProperties(context.configMap());
|
||||
return upload(context, properties).map(
|
||||
objectDetail -> this.buildAttachment(context, properties, objectDetail));
|
||||
return upload(context, properties)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.map(objectDetail -> this.buildAttachment(properties, objectDetail));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Attachment> delete(DeleteContext deleteContext) {
|
||||
return Mono.just(deleteContext).filter(context -> this.shouldHandle(context.policy()))
|
||||
.doOnNext(context -> {
|
||||
var annotations = context.attachment().getMetadata().getAnnotations();
|
||||
if (annotations == null || !annotations.containsKey(OBJECT_KEY)) {
|
||||
return;
|
||||
.flatMap(context -> {
|
||||
var objectKey = getObjectKey(context.attachment());
|
||||
if (objectKey == null) {
|
||||
return Mono.just(context);
|
||||
}
|
||||
var objectName = annotations.get(OBJECT_KEY);
|
||||
var properties = getProperties(deleteContext.configMap());
|
||||
var client = buildOsClient(properties);
|
||||
ossExecute(() -> {
|
||||
log.info("{}/{} is being deleted from S3ObjectStorage", properties.getBucket(),
|
||||
objectName);
|
||||
client.deleteObject(properties.getBucket(), objectName);
|
||||
log.info("{}/{} was deleted successfully from S3ObjectStorage", properties.getBucket(),
|
||||
objectName);
|
||||
return null;
|
||||
}, client::shutdown);
|
||||
}).map(DeleteContext::attachment);
|
||||
return Mono.using(() -> buildS3Client(properties),
|
||||
client -> Mono.fromCallable(
|
||||
() -> client.deleteObject(DeleteObjectRequest.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.key(objectKey)
|
||||
.build())).subscribeOn(Schedulers.boundedElastic()),
|
||||
S3Client::close)
|
||||
.doOnNext(response -> {
|
||||
checkResult(response, "delete object");
|
||||
log.info("Delete object {} from bucket {} successfully",
|
||||
objectKey, properties.getBucket());
|
||||
})
|
||||
.thenReturn(context);
|
||||
})
|
||||
.map(DeleteContext::attachment);
|
||||
}
|
||||
|
||||
<T> T ossExecute(Supplier<T> runnable, Runnable finalizer) {
|
||||
try {
|
||||
return runnable.get();
|
||||
} catch (AmazonServiceException ase) {
|
||||
log.error("""
|
||||
Caught an AmazonServiceException, which means your request made it to S3ObjectStorage, but was
|
||||
rejected with an error response for some reason.
|
||||
Error message: {}
|
||||
""", ase.getMessage());
|
||||
throw Exceptions.propagate(ase);
|
||||
} catch (SdkClientException sce) {
|
||||
log.error("""
|
||||
Caught an SdkClientException, which means the client encountered a serious internal
|
||||
problem while trying to communicate with S3ObjectStorage, such as not being able to access
|
||||
the network.
|
||||
Error message: {}
|
||||
""", sce.getMessage());
|
||||
throw Exceptions.propagate(sce);
|
||||
} finally {
|
||||
if (finalizer != null) {
|
||||
finalizer.run();
|
||||
}
|
||||
@Override
|
||||
public Mono<URI> getSharedURL(Attachment attachment, Policy policy, ConfigMap configMap,
|
||||
Duration ttl) {
|
||||
if (!this.shouldHandle(policy)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var objectKey = getObjectKey(attachment);
|
||||
if (objectKey == null) {
|
||||
return Mono.error(new IllegalArgumentException(
|
||||
"Cannot obtain object key from attachment " + attachment.getMetadata().getName()));
|
||||
}
|
||||
var properties = getProperties(configMap);
|
||||
|
||||
return Mono.using(() -> buildS3Presigner(properties),
|
||||
s3Presigner -> {
|
||||
var getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.key(objectKey)
|
||||
.build();
|
||||
var presignedRequest = GetObjectPresignRequest.builder()
|
||||
.signatureDuration(ttl)
|
||||
.getObjectRequest(getObjectRequest)
|
||||
.build();
|
||||
var presignedGetObjectRequest = s3Presigner.presignGetObject(presignedRequest);
|
||||
var presignedURL = presignedGetObjectRequest.url();
|
||||
try {
|
||||
return Mono.just(presignedURL.toURI());
|
||||
} catch (URISyntaxException e) {
|
||||
return Mono.error(
|
||||
new RuntimeException("Failed to convert URL " + presignedURL + " to URI."));
|
||||
}
|
||||
},
|
||||
SdkPresigner::close)
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<URI> getPermalink(Attachment attachment, Policy policy, ConfigMap configMap) {
|
||||
if (!this.shouldHandle(policy)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var objectKey = getObjectKey(attachment);
|
||||
if (objectKey == null) {
|
||||
// fallback to default handler for backward compatibility
|
||||
return Mono.empty();
|
||||
}
|
||||
var properties = getProperties(configMap);
|
||||
var objectURL = getObjectURL(properties, objectKey);
|
||||
var urlSuffix = getUrlSuffixAnnotation(attachment);
|
||||
if (StringUtils.isNotBlank(urlSuffix)) {
|
||||
objectURL += urlSuffix;
|
||||
}
|
||||
return Mono.just(URI.create(objectURL));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getObjectKey(Attachment attachment) {
|
||||
var annotations = attachment.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
return null;
|
||||
}
|
||||
return annotations.get(OBJECT_KEY);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getUrlSuffixAnnotation(Attachment attachment) {
|
||||
var annotations = attachment.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
return null;
|
||||
}
|
||||
return annotations.get(URL_SUFFIX_ANNO_KEY);
|
||||
}
|
||||
|
||||
S3OsProperties getProperties(ConfigMap configMap) {
|
||||
@@ -105,95 +188,286 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
return JsonUtils.jsonToObject(settingJson, S3OsProperties.class);
|
||||
}
|
||||
|
||||
Attachment buildAttachment(UploadContext uploadContext, S3OsProperties properties,
|
||||
ObjectDetail objectDetail) {
|
||||
String externalLink;
|
||||
if (StringUtils.isBlank(properties.getDomain())) {
|
||||
var host = properties.getBucket() + "." + properties.getEndpoint();
|
||||
externalLink = properties.getProtocol() + "://" + host + "/" + objectDetail.objectName();
|
||||
} else {
|
||||
externalLink = properties.getProtocol() + "://" + properties.getDomain() + "/" + objectDetail.objectName();
|
||||
}
|
||||
Attachment buildAttachment(S3OsProperties properties, ObjectDetail objectDetail) {
|
||||
String externalLink = getObjectURL(properties, objectDetail.uploadState.objectKey);
|
||||
var urlSuffix = UrlUtils.findUrlSuffix(properties.getUrlSuffixes(),
|
||||
objectDetail.uploadState.fileName);
|
||||
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(UUID.randomUUID().toString());
|
||||
metadata.setAnnotations(
|
||||
Map.of(OBJECT_KEY, objectDetail.objectName(), Constant.EXTERNAL_LINK_ANNO_KEY,
|
||||
UriUtils.encodePath(externalLink, StandardCharsets.UTF_8)));
|
||||
|
||||
var annotations = new HashMap<>(Map.of(OBJECT_KEY, objectDetail.uploadState.objectKey));
|
||||
if (StringUtils.isNotBlank(urlSuffix)) {
|
||||
externalLink += urlSuffix;
|
||||
annotations.put(URL_SUFFIX_ANNO_KEY, urlSuffix);
|
||||
}
|
||||
annotations.put(Constant.EXTERNAL_LINK_ANNO_KEY, externalLink);
|
||||
metadata.setAnnotations(annotations);
|
||||
|
||||
var objectMetadata = objectDetail.objectMetadata();
|
||||
var spec = new AttachmentSpec();
|
||||
spec.setSize(objectMetadata.getContentLength());
|
||||
spec.setDisplayName(uploadContext.file().filename());
|
||||
spec.setMediaType(objectMetadata.getContentType());
|
||||
spec.setSize(objectMetadata.contentLength());
|
||||
spec.setDisplayName(objectDetail.uploadState.fileName);
|
||||
spec.setMediaType(objectMetadata.contentType());
|
||||
|
||||
var attachment = new Attachment();
|
||||
attachment.setMetadata(metadata);
|
||||
attachment.setSpec(spec);
|
||||
log.info("Upload object {} to bucket {} successfully", objectDetail.uploadState.objectKey,
|
||||
properties.getBucket());
|
||||
return attachment;
|
||||
}
|
||||
|
||||
AmazonS3 buildOsClient(S3OsProperties properties) {
|
||||
return AmazonS3ClientBuilder.standard()
|
||||
.withCredentials(new AWSStaticCredentialsProvider(
|
||||
new BasicAWSCredentials(properties.getAccessKey(), properties.getAccessSecret())))
|
||||
.withEndpointConfiguration(
|
||||
new AwsClientBuilder.EndpointConfiguration(
|
||||
properties.getEndpointProtocol() + "://" + properties.getEndpoint(),
|
||||
properties.getRegion()))
|
||||
.withPathStyleAccessEnabled(false)
|
||||
.withChunkedEncodingDisabled(true)
|
||||
.build();
|
||||
String getObjectURL(S3OsProperties properties, String objectKey) {
|
||||
String objectURL;
|
||||
if (StringUtils.isBlank(properties.getDomain())) {
|
||||
String host;
|
||||
if (properties.getEnablePathStyleAccess()) {
|
||||
host = properties.getEndpoint() + "/" + properties.getBucket();
|
||||
} else {
|
||||
host = properties.getBucket() + "." + properties.getEndpoint();
|
||||
}
|
||||
objectURL = properties.getProtocol() + "://" + host + "/" + objectKey;
|
||||
} else {
|
||||
objectURL = properties.getProtocol() + "://" + properties.getDomain() + "/" + objectKey;
|
||||
}
|
||||
return UriUtils.encodePath(objectURL, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
S3Client buildS3Client(S3OsProperties properties) {
|
||||
return S3Client.builder()
|
||||
.region(Region.of(properties.getRegion()))
|
||||
.endpointOverride(
|
||||
URI.create(properties.getEndpointProtocol() + "://" + properties.getEndpoint()))
|
||||
.credentialsProvider(() -> AwsBasicCredentials.create(properties.getAccessKey(),
|
||||
properties.getAccessSecret()))
|
||||
.serviceConfiguration(S3Configuration.builder()
|
||||
.chunkedEncodingEnabled(false)
|
||||
.pathStyleAccessEnabled(properties.getEnablePathStyleAccess())
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private S3Presigner buildS3Presigner(S3OsProperties properties) {
|
||||
return S3Presigner.builder()
|
||||
.region(Region.of(properties.getRegion()))
|
||||
.endpointOverride(
|
||||
URI.create(properties.getEndpointProtocol() + "://" + properties.getEndpoint()))
|
||||
.credentialsProvider(() -> AwsBasicCredentials.create(properties.getAccessKey(),
|
||||
properties.getAccessSecret()))
|
||||
.serviceConfiguration(S3Configuration.builder()
|
||||
.chunkedEncodingEnabled(false)
|
||||
.pathStyleAccessEnabled(properties.getEnablePathStyleAccess())
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
Flux<DataBuffer> reshape(Publisher<DataBuffer> content, int bufferSize) {
|
||||
var dataBufferFactory = DefaultDataBufferFactory.sharedInstance;
|
||||
return Flux.<ByteBuffer>create(sink -> {
|
||||
var byteBuffer = ByteBuffer.allocate(bufferSize);
|
||||
Flux.from(content)
|
||||
.doOnNext(dataBuffer -> {
|
||||
var count = dataBuffer.readableByteCount();
|
||||
for (var i = 0; i < count; i++) {
|
||||
byteBuffer.put(dataBuffer.read());
|
||||
// Emit the buffer when buffer
|
||||
if (!byteBuffer.hasRemaining()) {
|
||||
sink.next(deepCopy(byteBuffer));
|
||||
byteBuffer.clear();
|
||||
}
|
||||
}
|
||||
})
|
||||
.doOnComplete(() -> {
|
||||
// Emit the last part of buffer.
|
||||
if (byteBuffer.position() > 0) {
|
||||
sink.next(deepCopy(byteBuffer));
|
||||
}
|
||||
})
|
||||
.subscribe(DataBufferUtils::release, sink::error, sink::complete,
|
||||
Context.of(sink.contextView()));
|
||||
})
|
||||
.map(dataBufferFactory::wrap)
|
||||
.cast(DataBuffer.class)
|
||||
.doOnDiscard(DataBuffer.class, DataBufferUtils::release);
|
||||
}
|
||||
|
||||
ByteBuffer deepCopy(ByteBuffer src) {
|
||||
src.flip();
|
||||
var dest = ByteBuffer.allocate(src.limit());
|
||||
dest.put(src);
|
||||
src.rewind();
|
||||
dest.flip();
|
||||
return dest;
|
||||
}
|
||||
|
||||
Mono<ObjectDetail> upload(UploadContext uploadContext, S3OsProperties properties) {
|
||||
return Mono.fromCallable(() -> {
|
||||
var client = buildOsClient(properties);
|
||||
// build object name
|
||||
var originFilename = uploadContext.file().filename();
|
||||
var objectName = properties.getObjectName(originFilename);
|
||||
return Mono.using(() -> buildS3Client(properties),
|
||||
client -> {
|
||||
var uploadState = new UploadState(properties, uploadContext.file().filename(), true);
|
||||
|
||||
var pos = new PipedOutputStream();
|
||||
var pis = new PipedInputStream(pos);
|
||||
DataBufferUtils.write(uploadContext.file().content(), pos)
|
||||
.subscribeOn(Schedulers.boundedElastic()).doOnComplete(() -> {
|
||||
try {
|
||||
pos.close();
|
||||
} catch (IOException ioe) {
|
||||
// close the stream quietly
|
||||
log.warn("Failed to close output stream", ioe);
|
||||
}
|
||||
}).subscribe(DataBufferUtils.releaseConsumer());
|
||||
var content = uploadContext.file().content();
|
||||
|
||||
final var bucket = properties.getBucket();
|
||||
var metadata = new ObjectMetadata();
|
||||
var contentType = MediaTypeFactory.getMediaType(originFilename)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM).toString();
|
||||
metadata.setContentType(contentType);
|
||||
var request = new PutObjectRequest(bucket, objectName, pis, metadata);
|
||||
log.info("Uploading {} into S3ObjectStorage {}/{}/{}", originFilename,
|
||||
properties.getEndpoint(), bucket, objectName);
|
||||
return checkFileExistsAndRename(uploadState, client)
|
||||
// init multipart upload
|
||||
.flatMap(state -> Mono.fromCallable(() -> client.createMultipartUpload(
|
||||
CreateMultipartUploadRequest.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.contentType(state.contentType)
|
||||
.key(state.objectKey)
|
||||
.build())))
|
||||
.doOnNext((response) -> {
|
||||
checkResult(response, "createMultipartUpload");
|
||||
uploadState.uploadId = response.uploadId();
|
||||
})
|
||||
.thenMany(reshape(content, MULTIPART_MIN_PART_SIZE))
|
||||
// buffer to part
|
||||
.windowUntil((buffer) -> {
|
||||
uploadState.buffered += buffer.readableByteCount();
|
||||
if (uploadState.buffered >= MULTIPART_MIN_PART_SIZE) {
|
||||
uploadState.buffered = 0;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
// upload part
|
||||
.concatMap((window) -> window.collectList().flatMap((bufferList) -> {
|
||||
var buffer = S3OsAttachmentHandler.concatBuffers(bufferList);
|
||||
return uploadPart(uploadState, buffer, client);
|
||||
}))
|
||||
.reduce(uploadState, (state, completedPart) -> {
|
||||
state.completedParts.put(completedPart.partNumber(), completedPart);
|
||||
return state;
|
||||
})
|
||||
// complete multipart upload
|
||||
.flatMap((state) -> Mono.just(client.completeMultipartUpload(
|
||||
CompleteMultipartUploadRequest
|
||||
.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.uploadId(state.uploadId)
|
||||
.multipartUpload(CompletedMultipartUpload.builder()
|
||||
.parts(state.completedParts.values())
|
||||
.build())
|
||||
.key(state.objectKey)
|
||||
.build())
|
||||
))
|
||||
// get object metadata
|
||||
.flatMap((response) -> {
|
||||
checkResult(response, "completeUpload");
|
||||
return Mono.just(client.headObject(
|
||||
HeadObjectRequest.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.key(uploadState.objectKey)
|
||||
.build()
|
||||
));
|
||||
})
|
||||
// build object detail
|
||||
.map((response) -> {
|
||||
checkResult(response, "getMetadata");
|
||||
return new ObjectDetail(uploadState, response);
|
||||
})
|
||||
// close client
|
||||
.doFinally((signalType) -> {
|
||||
if (uploadState.needRemoveMapKey) {
|
||||
uploadingFile.remove(uploadState.getUploadingMapKey());
|
||||
}
|
||||
});
|
||||
},
|
||||
SdkAutoCloseable::close);
|
||||
}
|
||||
|
||||
return ossExecute(() -> {
|
||||
var result = client.putObject(request);
|
||||
if (log.isDebugEnabled()) {
|
||||
debug(result);
|
||||
private Mono<UploadState> checkFileExistsAndRename(UploadState uploadState,
|
||||
S3Client s3client) {
|
||||
return Mono.defer(() -> {
|
||||
// deduplication of uploading files
|
||||
if (uploadingFile.put(uploadState.getUploadingMapKey(),
|
||||
uploadState.getUploadingMapKey()) != null) {
|
||||
return Mono.error(new FileAlreadyExistsException("文件 " + uploadState.objectKey
|
||||
+
|
||||
" 已存在,建议更名后重试。[local]"));
|
||||
}
|
||||
var objectMetadata = client.getObjectMetadata(bucket, objectName);
|
||||
return new ObjectDetail(bucket, objectName, objectMetadata);
|
||||
}, client::shutdown);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
uploadState.needRemoveMapKey = true;
|
||||
// check whether file exists
|
||||
return Mono.fromSupplier(() -> s3client.headObject(HeadObjectRequest.builder()
|
||||
.bucket(uploadState.properties.getBucket())
|
||||
.key(uploadState.objectKey)
|
||||
.build()))
|
||||
.onErrorResume(NoSuchKeyException.class, e -> {
|
||||
var builder = HeadObjectResponse.builder();
|
||||
builder.sdkHttpResponse(SdkHttpResponse.builder().statusCode(404).build());
|
||||
return Mono.just(builder.build());
|
||||
})
|
||||
.flatMap(response -> {
|
||||
if (response != null && response.sdkHttpResponse() != null
|
||||
&& response.sdkHttpResponse().isSuccessful()) {
|
||||
return Mono.error(
|
||||
new FileAlreadyExistsException("文件 " + uploadState.objectKey
|
||||
+ " 已存在,建议更名后重试。[remote]"));
|
||||
} else {
|
||||
return Mono.just(uploadState);
|
||||
}
|
||||
});
|
||||
})
|
||||
.retryWhen(Retry.max(3)
|
||||
.filter(FileAlreadyExistsException.class::isInstance)
|
||||
.doAfterRetry((retrySignal) -> {
|
||||
if (uploadState.needRemoveMapKey) {
|
||||
uploadingFile.remove(uploadState.getUploadingMapKey());
|
||||
uploadState.needRemoveMapKey = false;
|
||||
}
|
||||
uploadState.randomDuplicateFileName();
|
||||
})
|
||||
)
|
||||
.onErrorMap(Exceptions::isRetryExhausted,
|
||||
throwable -> new ServerWebInputException(throwable.getCause().getMessage()));
|
||||
}
|
||||
|
||||
void debug(PutObjectResult result) {
|
||||
log.debug("""
|
||||
PutObjectResult: VersionId: {}, ETag: {}, ContentMd5: {}, ExpirationTime: {}, ExpirationTimeRuleId: {},
|
||||
response RawMetadata: {}, UserMetadata: {}
|
||||
""", result.getVersionId(), result.getETag(), result.getContentMd5(), result.getExpirationTime(),
|
||||
result.getExpirationTimeRuleId(), result.getMetadata().getRawMetadata(),
|
||||
result.getMetadata().getUserMetadata());
|
||||
|
||||
private Mono<CompletedPart> uploadPart(UploadState uploadState, ByteBuffer buffer,
|
||||
S3Client s3client) {
|
||||
final int partNumber = ++uploadState.partCounter;
|
||||
return Mono.just(s3client.uploadPart(UploadPartRequest.builder()
|
||||
.bucket(uploadState.properties.getBucket())
|
||||
.key(uploadState.objectKey)
|
||||
.partNumber(partNumber)
|
||||
.uploadId(uploadState.uploadId)
|
||||
.contentLength((long) buffer.capacity())
|
||||
.build(),
|
||||
RequestBody.fromByteBuffer(buffer)))
|
||||
.map((uploadPartResult) -> {
|
||||
checkResult(uploadPartResult, "uploadPart");
|
||||
return CompletedPart.builder()
|
||||
.eTag(uploadPartResult.eTag())
|
||||
.partNumber(partNumber)
|
||||
.build();
|
||||
});
|
||||
}
|
||||
|
||||
private static void checkResult(SdkResponse result, String operation) {
|
||||
log.info("operation: {}, result: {}", operation, result);
|
||||
if (result.sdkHttpResponse() == null || !result.sdkHttpResponse().isSuccessful()) {
|
||||
log.error("Failed to upload object, response: {}", result.sdkHttpResponse());
|
||||
throw new ServerErrorException("对象存储响应错误,无法将对象上传到S3对象存储", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static ByteBuffer concatBuffers(List<DataBuffer> buffers) {
|
||||
int partSize = 0;
|
||||
for (DataBuffer b : buffers) {
|
||||
partSize += b.readableByteCount();
|
||||
}
|
||||
|
||||
ByteBuffer partData = ByteBuffer.allocate(partSize);
|
||||
buffers.forEach((buffer) -> partData.put(buffer.toByteBuffer()));
|
||||
|
||||
// Reset read pointer to first byte
|
||||
partData.rewind();
|
||||
|
||||
return partData;
|
||||
}
|
||||
|
||||
|
||||
boolean shouldHandle(Policy policy) {
|
||||
if (policy == null || policy.getSpec() == null ||
|
||||
policy.getSpec().getTemplateName() == null) {
|
||||
@@ -203,7 +477,44 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
return "s3os".equals(templateName);
|
||||
}
|
||||
|
||||
record ObjectDetail(String bucketName, String objectName, ObjectMetadata objectMetadata) {
|
||||
record ObjectDetail(UploadState uploadState, HeadObjectResponse objectMetadata) {
|
||||
}
|
||||
|
||||
static class UploadState {
|
||||
final S3OsProperties properties;
|
||||
final String originalFileName;
|
||||
String uploadId;
|
||||
int partCounter;
|
||||
Map<Integer, CompletedPart> completedParts = new HashMap<>();
|
||||
int buffered = 0;
|
||||
String contentType;
|
||||
String fileName;
|
||||
String objectKey;
|
||||
boolean needRemoveMapKey = false;
|
||||
|
||||
public UploadState(S3OsProperties properties, String fileName, boolean needRandomJudge) {
|
||||
this.properties = properties;
|
||||
this.originalFileName = fileName;
|
||||
|
||||
if (needRandomJudge) {
|
||||
fileName = FileNameUtils.getRandomFilename(fileName,
|
||||
properties.getRandomStringLength(), properties.getRandomFilenameMode());
|
||||
}
|
||||
|
||||
this.fileName = fileName;
|
||||
this.objectKey = properties.getObjectName(fileName);
|
||||
this.contentType = MediaTypeFactory.getMediaType(fileName)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM).toString();
|
||||
}
|
||||
|
||||
public String getUploadingMapKey() {
|
||||
return properties.getBucket() + "/" + objectKey;
|
||||
}
|
||||
|
||||
public void randomDuplicateFileName() {
|
||||
this.fileName = FileNameUtils.randomFilenameWithString(originalFileName, 4);
|
||||
this.objectKey = properties.getObjectName(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,8 +1,14 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
class S3OsProperties {
|
||||
|
||||
@@ -10,6 +16,8 @@ class S3OsProperties {
|
||||
|
||||
private Protocol endpointProtocol = Protocol.https;
|
||||
|
||||
private Boolean enablePathStyleAccess = false;
|
||||
|
||||
private String endpoint;
|
||||
|
||||
private String accessKey;
|
||||
@@ -21,6 +29,10 @@ class S3OsProperties {
|
||||
*/
|
||||
private String location;
|
||||
|
||||
private String randomFilenameMode = "none";
|
||||
|
||||
private Integer randomStringLength = 8;
|
||||
|
||||
private Protocol protocol = Protocol.https;
|
||||
|
||||
/**
|
||||
@@ -31,10 +43,21 @@ class S3OsProperties {
|
||||
|
||||
private String region = "Auto";
|
||||
|
||||
private List<urlSuffixItem> urlSuffixes;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public static class urlSuffixItem {
|
||||
private String fileSuffix;
|
||||
private String urlSuffix;
|
||||
}
|
||||
|
||||
public String getObjectName(String filename) {
|
||||
var objectName = filename;
|
||||
if (StringUtils.hasText(getLocation())) {
|
||||
objectName = getLocation() + "/" + objectName;
|
||||
var finalName = FilePathUtils.getFilePathByPlaceholder(getLocation());
|
||||
if (StringUtils.hasText(finalName)) {
|
||||
objectName = finalName + "/" + objectName;
|
||||
}
|
||||
return objectName;
|
||||
}
|
||||
@@ -62,6 +85,16 @@ class S3OsProperties {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
public void setRandomStringLength(String randomStringLength) { // if you use Integer, it will throw Error.
|
||||
try {
|
||||
int length = Integer.parseInt(randomStringLength);
|
||||
if (length >= 4 && length <= 16) {
|
||||
this.randomStringLength = length;
|
||||
}
|
||||
}
|
||||
catch (NumberFormatException ignored) { }
|
||||
}
|
||||
|
||||
public void setRegion(String region) {
|
||||
if (!StringUtils.hasText(region)) {
|
||||
this.region = "Auto";
|
||||
|
@@ -2,6 +2,7 @@ package run.halo.s3os;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public class UrlUtils {
|
||||
private static final List<String> HTTP_PREFIXES = Arrays.asList("http://", "https://");
|
||||
@@ -17,4 +18,21 @@ public class UrlUtils {
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
public static String findUrlSuffix(List<S3OsProperties.urlSuffixItem> urlSuffixList,
|
||||
String fileName) {
|
||||
if (StringUtils.isBlank(fileName) || urlSuffixList == null) {
|
||||
return null;
|
||||
}
|
||||
fileName = fileName.toLowerCase();
|
||||
for (S3OsProperties.urlSuffixItem item : urlSuffixList) {
|
||||
String[] fileSuffixes = item.getFileSuffix().split(",");
|
||||
for (String suffix : fileSuffixes) {
|
||||
if (fileName.endsWith("." + suffix.trim().toLowerCase())) {
|
||||
return item.getUrlSuffix();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
3
src/main/resources/console/main.js
Normal file
3
src/main/resources/console/main.js
Normal file
File diff suppressed because one or more lines are too long
@@ -27,18 +27,31 @@ spec:
|
||||
- label: HTTP
|
||||
value: http
|
||||
validation: required
|
||||
- $formkit: select
|
||||
name: enablePathStyleAccess
|
||||
label: Endpoint 访问风格
|
||||
options:
|
||||
- label: Virtual Hosted Style
|
||||
value: false
|
||||
- label: Path Style
|
||||
value: true
|
||||
value: false
|
||||
validation: required
|
||||
- $formkit: text
|
||||
name: endpoint
|
||||
label: EndPoint
|
||||
placeholder: 请填写不带bucket-name的Endpoint
|
||||
validation: required
|
||||
help: 协议头请在上方设置,此处无需以"http://"或"https://"开头,系统会自动拼接
|
||||
- $formkit: password
|
||||
name: accessKey
|
||||
label: Access Key
|
||||
label: Access Key ID
|
||||
placeholder: 存储桶用户标识(用户名)
|
||||
validation: required
|
||||
- $formkit: password
|
||||
name: accessSecret
|
||||
label: Access Secret
|
||||
label: Access Key Secret
|
||||
placeholder: 存储桶密钥(密码)
|
||||
validation: required
|
||||
- $formkit: text
|
||||
name: region
|
||||
@@ -49,6 +62,30 @@ spec:
|
||||
name: location
|
||||
label: 上传目录
|
||||
placeholder: 如不填写,则默认上传到根目录
|
||||
help: 支持使用 ${year} ${month} ${day} 占位符
|
||||
- $formkit: select
|
||||
name: randomFilenameMode
|
||||
label: 上传时重命名文件方式
|
||||
options:
|
||||
- label: 保留原文件名
|
||||
value: none
|
||||
- label: 使用原文件名 + 随机字符串
|
||||
value: withString
|
||||
- label: 使用日期 + 随机字符串
|
||||
value: dateWithString
|
||||
- label: 使用日期时间 + 随机字符串
|
||||
value: datetimeWithString
|
||||
- label: 使用随机字符串
|
||||
value: string
|
||||
- label: 使用UUID
|
||||
value: uuid
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: randomStringLength
|
||||
label: 随机字符串长度
|
||||
min: 4
|
||||
max: 16
|
||||
placeholder: 仅在重命名文件时需要随机字符串时填写(支持4~16位, 默认为8位)
|
||||
- $formkit: select
|
||||
name: protocol
|
||||
label: 绑定域名协议
|
||||
@@ -63,3 +100,20 @@ spec:
|
||||
label: 绑定域名(CDN域名)
|
||||
placeholder: 如不设置,那么将使用 Bucket + EndPoint 作为域名
|
||||
help: 协议头请在上方设置,此处无需以"http://"或"https://"开头,系统会自动拼接
|
||||
- $formkit: repeater
|
||||
name: urlSuffixes
|
||||
label: 网址后缀
|
||||
help: 用于对指定文件类型的网址添加后缀处理参数,优先级从上到下只取第一个匹配项
|
||||
value: [ ]
|
||||
min: 0
|
||||
children:
|
||||
- $formkit: text
|
||||
name: fileSuffix
|
||||
label: 文件后缀
|
||||
placeholder: 以半角逗号分隔,例如:jpg,jpeg,png,gif
|
||||
validation: required
|
||||
- $formkit: text
|
||||
name: urlSuffix
|
||||
label: 网址后缀
|
||||
placeholder: 例如:?imageMogr2/format/webp
|
||||
validation: required
|
23
src/main/resources/extensions/s3os-role-template.yaml
Normal file
23
src/main/resources/extensions/s3os-role-template.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: "Role"
|
||||
metadata:
|
||||
name: role-template-s3os-link
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/dependencies: |
|
||||
[ "role-template-manage-attachments", "role-template-view-plugins" ]
|
||||
rbac.authorization.halo.run/module: "S3 Attachments Management"
|
||||
rbac.authorization.halo.run/display-name: "S3 Link"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["plugin:s3os:link"]
|
||||
rules:
|
||||
- apiGroups: [ "s3os.halo.run" ]
|
||||
resources: [ "policies" ]
|
||||
resourceNames: [ "s3" ]
|
||||
verbs: [ "get", "list" ]
|
||||
- apiGroups: [ "s3os.halo.run" ]
|
||||
resources: [ "objects" ]
|
||||
verbs: [ "get", "list" ]
|
||||
- nonResourceURLs: ["/apis/s3os.halo.run/v1alpha1/attachments/link"]
|
||||
verbs: [ "create" ]
|
@@ -5,7 +5,7 @@ metadata:
|
||||
spec:
|
||||
forms:
|
||||
- group: basic
|
||||
label: 基本设置
|
||||
label: 使用提示
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
help: 请前往 “附件 - 存储策略” 添加策略
|
||||
|
@@ -4,16 +4,15 @@ metadata:
|
||||
name: PluginS3ObjectStorage
|
||||
spec:
|
||||
enabled: true
|
||||
version: 1.2.1
|
||||
requires: ">=2.0.0"
|
||||
requires: ">=2.9.0"
|
||||
author:
|
||||
name: longjuan
|
||||
website: https://github.com/longjuan
|
||||
name: Halo OSS Team
|
||||
website: https://github.com/halo-dev
|
||||
logo: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAABUdJREFUeF7tmmtoXFUQx3+zMYGi/RJRULRFsL6oim2klVK0CrVqKxjoJ0Vt1bR7t6lURagiFlGpFPogydklVo1i/VB84QMfqFijpflQrI+IYj7FF+0HMbUEI82O3NNtzG6y956bvXd3IXvgksCd+c/M/8w598ycFWb5kFkePw0CGhkwyxloLIFZngCNTbCxBBpLoI4Y0A6aydNKC62kaAXGSDHCv4zQzIh0MRa3uzVdAppmMSmWoVyL0IZyWUiA3wL7gI/FcCQOMmpCQCHwDpSOCoIYQOmWLK9WgFHdr4Bu4wyOsR14uBKni3SFdxhnl+T4fCaYVcsAzbCEPNsRbpiJow46u8TwkINcCX9RNWYgr2nWIrwAzJ2BehSVAVpYJbv5y1Up8QwoBL/f1aGY5JaJ4aALVqIEqMc24EkXRxKQmSeGX8JwEyNAPR4AesMcSPD9ECdpk15GgmwkQoB2spxxPgWaEwzQBfptMdxRfQI8PgJWuniYuIyyQbLlMzH2DFCPDUAu8cDcDRwWQ1s58VgJ0E7OYZyvgAXu/lVBMiAL4iXA4z5gbxVCimpiQAxLp1OKl4AMfSj3RPWuKvJ5FkmOr0ttxUuAx9/AWVUJKKoR5QnJ8nRsBOh65tJiy9nFYJ+fwB586nUcEsN1FROgHncDG2EqWIWR/wpcUCFGsPooc6SPfyYLOS8BzbAI5RlgVQJObrXdH9iZAPb/kCkul25+jEyAejboDxJyLiMG42Orx2fAioTs+LC3iOHDSARohs0oexJxStgtPWw5jZ34IUpJS7b4kBa4BDTDTSifJBI8vC+G1ZOxdQtzGGMIOD8hm1vF2I7UxAgmwLNpn8SaHyTPjZLjWGmg6tl9YCIrYiVCuVOyvOZEQGG3fzlWB06BjZJipXTbI/OUoWmuQvgmAbs+5HIxfOlKwCFgSQKOrBNDXxCuerwH3Ba77ZPMl16GQwnQjSwkxXexO6A8JdnwDpF6tANvxG1fzNQu+LR7QEKtrFfEuNcJ6vEzcHGMJEzZdH3scgRkC6e9eOwr/Qi3iuGEK6B6PAo85yofKid40oMfV9EoR8CbENxKCjVYvNCWSg8DUXQKn8TRKDqBstOs/6AMiLelVaYUDQtO0zyPcH+YnMP7d8Vw+3Ry5TLgJeBeB2BXEX8mDwDHEUZQWzafEBNcPca2FwlrpMd+WaaM6QnI8CyKX6AkN/KsmHyfZ9voQjfKUbDPOMoCxF6TVzLKzn75JZBmOcIXlVgN0y39JGka/yOVDtOL/D5g9ssS4L9Qz5aNl0Y26KZwRAzXTBZVj8PAIjd1Z6ntYoIzuWwtoBkeK9T/ztacBUurwA1cQRODzvpugvvEcFeYaHkCHuFMRvGPwwvDQCK+P0qexZLjt9N6mmE9am+P4xnKn5LlbBew4GowzTqEF12AnGXytEuOtyaC30aKY7YRcr0zRrDg92K40hUrtCWmGfajrHUFDJHzf9tzc8na9zu1j8eCL/RKj72Zch6hBPhI6tEFbHJGnV5wSEzxjVGMrbZBlB2SJXL57kRAgQS/G7wDODcyEUKX9LC5ZOb9+wO/lV5J9+c4sIsmdkoX/v+RhzMBloRNXESeB8E+4UNtSbtHsvSXCqt/1oB2xNYc88PBiiT8uuIgTeyVLn6IqFskHomAiY0rwyUoa1BWI3YGzyu8+wPld1K8Tp5+yeL/ri90aCdXM26JuBCYN/GcOjYPIwzbv3CYJg5IF/4dQixjRgTEYrlOQBoE1MlE1MyNRgbUjPo6MdzIgDqZiJq50ciAmlFfJ4YbGVAnE1EzN2Z9BvwH8mZfUOuuxhIAAAAASUVORK5CYII=
|
||||
settingName: s3os-settings
|
||||
configMapName: s3os-configMap
|
||||
homepage: https://github.com/halo-sigs/plugin-s3
|
||||
homepage: https://github.com/halo-dev/plugin-s3
|
||||
displayName: "对象存储(Amazon S3 协议)"
|
||||
description: "提供兼容 Amazon S3 协议的对象存储策略,兼容阿里云、腾讯云、七牛云等"
|
||||
license:
|
||||
- name: "MIT"
|
||||
- name: "GPL-3.0"
|
||||
|
@@ -1,12 +1,19 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.core.extension.attachment.Policy;
|
||||
|
||||
class S3OsAttachmentHandlerTest {
|
||||
@@ -33,4 +40,57 @@ class S3OsAttachmentHandlerTest {
|
||||
// policy is null
|
||||
assertFalse(handler.shouldHandle(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reshapeDataBufferWithSmallerBufferSize() {
|
||||
var handler = new S3OsAttachmentHandler();
|
||||
var factory = DefaultDataBufferFactory.sharedInstance;
|
||||
var content = Flux.<DataBuffer>fromIterable(List.of(factory.wrap("halo".getBytes())));
|
||||
|
||||
StepVerifier.create(handler.reshape(content, 2))
|
||||
.assertNext(dataBuffer -> {
|
||||
var str = dataBuffer.toString(UTF_8);
|
||||
assertEquals("ha", str);
|
||||
})
|
||||
.assertNext(dataBuffer -> {
|
||||
var str = dataBuffer.toString(UTF_8);
|
||||
assertEquals("lo", str);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reshapeDataBufferWithBiggerBufferSize() {
|
||||
var handler = new S3OsAttachmentHandler();
|
||||
var factory = DefaultDataBufferFactory.sharedInstance;
|
||||
var content = Flux.<DataBuffer>fromIterable(List.of(factory.wrap("halo".getBytes())));
|
||||
|
||||
StepVerifier.create(handler.reshape(content, 10))
|
||||
.assertNext(dataBuffer -> {
|
||||
var str = dataBuffer.toString(UTF_8);
|
||||
assertEquals("halo", str);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reshapeDataBuffersWithBiggerBufferSize() {
|
||||
var handler = new S3OsAttachmentHandler();
|
||||
var factory = DefaultDataBufferFactory.sharedInstance;
|
||||
var content = Flux.<DataBuffer>fromIterable(List.of(
|
||||
factory.wrap("ha".getBytes()),
|
||||
factory.wrap("lo".getBytes())
|
||||
));
|
||||
|
||||
StepVerifier.create(handler.reshape(content, 3))
|
||||
.assertNext(dataBuffer -> {
|
||||
var str = dataBuffer.toString(UTF_8);
|
||||
assertEquals("hal", str);
|
||||
})
|
||||
.assertNext(dataBuffer -> {
|
||||
var str = dataBuffer.toString(UTF_8);
|
||||
assertEquals("o", str);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -12,4 +13,43 @@ class UrlUtilsTest {
|
||||
assert UrlUtils.removeHttpPrefix("http://www.example.com").equals("www.example.com");
|
||||
assert UrlUtils.removeHttpPrefix("https://www.example.com").equals("www.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFindUrlSuffix() {
|
||||
List<S3OsProperties.urlSuffixItem> urlSuffixList = List.of(
|
||||
new S3OsProperties.urlSuffixItem("jpg,png,gif", "?imageMogr2/format/webp"),
|
||||
new S3OsProperties.urlSuffixItem("pdf", "?123=123"),
|
||||
new S3OsProperties.urlSuffixItem("jpg", "?456=456")
|
||||
);
|
||||
|
||||
// 测试文件名为"example.jpg",期望匹配到"?imageMogr2/format/webp",只匹配第一个后缀
|
||||
String fileName1 = "example.jpg";
|
||||
String result1 = UrlUtils.findUrlSuffix(urlSuffixList, fileName1);
|
||||
assertEquals("?imageMogr2/format/webp", result1);
|
||||
|
||||
// 测试文件名为"Document.PDF",期望匹配到"?123=123",不区分大小写
|
||||
String fileName2 = "Document.PDF";
|
||||
String result2 = UrlUtils.findUrlSuffix(urlSuffixList, fileName2);
|
||||
assertEquals("?123=123", result2);
|
||||
|
||||
// 测试文件名为"unknown.txt",期望没有匹配项,返回null
|
||||
String fileName3 = "unknown.txt";
|
||||
String result3 = UrlUtils.findUrlSuffix(urlSuffixList, fileName3);
|
||||
assertNull(result3);
|
||||
|
||||
// 测试无后缀文件名"example",期望没有匹配项,返回null
|
||||
String fileName4 = "example";
|
||||
String result4 = UrlUtils.findUrlSuffix(urlSuffixList, fileName4);
|
||||
assertNull(result4);
|
||||
|
||||
// 测试空文件名,期望返回null
|
||||
String fileName5 = "";
|
||||
String result5 = UrlUtils.findUrlSuffix(urlSuffixList, fileName5);
|
||||
assertNull(result5);
|
||||
|
||||
// 测试urlSuffixList为null,期望返回null
|
||||
String fileName6 = "example";
|
||||
String result6 = UrlUtils.findUrlSuffix(null, fileName6);
|
||||
assertNull(result6);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user