mirror of
https://github.com/halo-dev/plugin-s3.git
synced 2025-10-15 14:40:46 +00:00
Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
40f66ff665 | ||
![]() |
cb968de154 | ||
![]() |
bdd62cbe12 | ||
![]() |
2a26171fc4 | ||
![]() |
6b62ce7aa4 | ||
![]() |
c60e31a033 | ||
![]() |
7a9b0de0c6 | ||
![]() |
5e7e6620fd | ||
![]() |
47b6a37d0a | ||
![]() |
68b1a88b14 | ||
![]() |
f4ec56b7bc | ||
![]() |
c5d4e719a7 | ||
![]() |
034b3f3ded | ||
![]() |
2503c6eba1 | ||
![]() |
84aa7d32ba | ||
![]() |
73bd9e9948 | ||
![]() |
71c9784b64 | ||
![]() |
a16bbde9dd | ||
![]() |
9efa4b97e5 | ||
![]() |
5c95a04a07 | ||
![]() |
a63f3f4dc3 | ||
![]() |
11cbfdb9bb | ||
![]() |
2b0dd98575 | ||
![]() |
8ff4acba6e |
17
.github/workflows/cd.yaml
vendored
Normal file
17
.github/workflows/cd.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
cd:
|
||||
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-cd.yaml@v1
|
||||
secrets:
|
||||
halo-username: ${{ secrets.HALO_USERNAME }}
|
||||
halo-password: ${{ secrets.HALO_PASSWORD }}
|
||||
permissions:
|
||||
contents: write
|
||||
with:
|
||||
app-id: app-Qxhpp
|
13
.github/workflows/ci.yaml
vendored
Normal file
13
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-ci.yaml@v1
|
138
.github/workflows/workflow.yaml
vendored
138
.github/workflows/workflow.yaml
vendored
@@ -1,138 +0,0 @@
|
||||
name: Build Plugin JAR File
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**"
|
||||
- "!**.md"
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
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
|
||||
version=${{ github.event.release.tag_name }}
|
||||
version=${version#v}
|
||||
sed -i "s/version=.*-SNAPSHOT$/version=$version/1" gradle.properties
|
||||
./gradlew clean build -x test
|
||||
- name: Archive plugin-s3 jar
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: plugin-s3
|
||||
path: |
|
||||
build/libs/*.jar
|
||||
retention-days: 1
|
||||
|
||||
github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.event_name == 'release'
|
||||
steps:
|
||||
- name: Download plugin-s3 jar
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: plugin-s3
|
||||
path: build/libs
|
||||
- name: Get Name of Artifact
|
||||
id: get_artifact
|
||||
run: |
|
||||
ARTIFACT_PATHNAME=$(ls build/libs/*.jar | head -n 1)
|
||||
ARTIFACT_NAME=$(basename ${ARTIFACT_PATHNAME})
|
||||
echo "Artifact pathname: ${ARTIFACT_PATHNAME}"
|
||||
echo "Artifact name: ${ARTIFACT_NAME}"
|
||||
echo "ARTIFACT_PATHNAME=${ARTIFACT_PATHNAME}" >> $GITHUB_ENV
|
||||
echo "ARTIFACT_NAME=${ARTIFACT_NAME}" >> $GITHUB_ENV
|
||||
echo "RELEASE_ID=${{ github.event.release.id }}" >> $GITHUB_ENV
|
||||
- name: Upload a Release Asset
|
||||
uses: actions/github-script@v2
|
||||
if: github.event_name == 'release'
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
console.log('environment', process.versions);
|
||||
|
||||
const fs = require('fs').promises;
|
||||
|
||||
const { repo: { owner, repo }, sha } = context;
|
||||
console.log({ owner, repo, sha });
|
||||
|
||||
const releaseId = process.env.RELEASE_ID
|
||||
const artifactPathName = process.env.ARTIFACT_PATHNAME
|
||||
const artifactName = process.env.ARTIFACT_NAME
|
||||
console.log('Releasing', releaseId, artifactPathName, artifactName)
|
||||
|
||||
await github.repos.uploadReleaseAsset({
|
||||
owner, repo,
|
||||
release_id: releaseId,
|
||||
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 }}
|
98
README.md
98
README.md
@@ -15,7 +15,7 @@
|
||||
* 在附件页面中点击上传按钮,选择好存储策略后上传文件即可上传到对应的对象存储中。
|
||||
* 在 Halo 2.11 以上版本中可在 Halo 设置界面中设定文章附件、头像等的默认存储策略。
|
||||
5. 使用“关联 S3 文件”功能:
|
||||
* 在插件管理界面中,点击“对象存储(Amazon S3 协议)”插件标题,然后选择“关联 S3 文件”选项卡。
|
||||
* 在左侧侧边导航栏中,点击工具,再点击 S3 关联。
|
||||
* 在此界面中,您可以浏览并选择已在对象存储中但不是通过 Halo 上传的文件,关联后会在 Halo 生成相应的附件记录。这些文件现在可以方便地在 Halo 中管理和使用。
|
||||
6. 使用“解除 S3 关联”功能:
|
||||
* 在附件页面中,找到由本插件管理的附件记录,点击更多操作(右侧的三个点)按钮,然后点击“解除 S3 关联”按钮。
|
||||
@@ -23,6 +23,14 @@
|
||||
|
||||
## 配置指南
|
||||
|
||||
### Bucket 桶名称
|
||||
|
||||
一般与服务商控制台中的空间名称一致。
|
||||
|
||||
> 注意部分服务商 s3 空间名 ≠ 空间名称,若出现“Access Denied”报错可检查 Bucket 是否正确。
|
||||
>
|
||||
> 可通过 S3Browser 查看桶列表,七牛云也可在“开发者平台-对象存储-空间概览-s3域名”中查看 s3 空间名。
|
||||
|
||||
### Endpoint 访问风格
|
||||
|
||||
请根据下方表格中的兼容访问风格选择,若您的服务商不在表格中,请自行查看服务商的 s3 兼容性文档或自行尝试。
|
||||
@@ -44,14 +52,6 @@
|
||||
|
||||
与服务商自己 API 的 Access Key 和 Access Secret 相同,详情查看对应服务商的文档。
|
||||
|
||||
### Bucket 桶名称
|
||||
|
||||
一般与服务商控制台中的空间名称一致。
|
||||
|
||||
> 注意部分服务商 s3 空间名 ≠ 空间名称,若出现“Access Denied”报错可检查 Bucket 是否正确。
|
||||
>
|
||||
> 可通过 S3Browser 查看桶列表,七牛云也可在“开发者平台-对象存储-空间概览-s3域名”中查看 s3 空间名。
|
||||
|
||||
### Region
|
||||
|
||||
一般留空即可。
|
||||
@@ -60,15 +60,79 @@
|
||||
>
|
||||
> Cloudflare 需要填写均为小写字母的 `auto`。
|
||||
|
||||
### 上传时重命名文件方式
|
||||
* **保留原文件名:** 默认使用上传时的文件名,如遇文件名冲突会自动使用`使用原文件名 + 随机字符串` 模式重命名。
|
||||
* **使用原文件名 + 随机字符串:** 上传时会自动重命名为原文件名 + 随机的小写英文字母,长度请在`随机字符串长度`中设置。
|
||||
* **使用日期 + 随机字符串:** 上传时会自动重命名为日期 + 随机的小写英文字母,例如 `2023-12-01-abcdefgh.png`。
|
||||
* **使用日期时间 + 随机字符串:** 上传时会自动重命名为日期时间 + 随机的小写英文字母,例如 `2023-12-01T09:30:01.123456789-abcdef.png`。
|
||||
* **使用随机字符串:** 上传时会自动重命名为随机的小写英文字母,长度请在`随机字符串长度`中设置。
|
||||
* **使用 UUID:** 上传时会自动重命名为随机的 UUID。
|
||||
### 上传目录
|
||||
|
||||
> 所有随机字符串的长度可在`随机字符串长度`中设置。
|
||||
上传到对象存储的目录,前后`/`可省略,例如`/halo`和`halo`是等价的。
|
||||
|
||||
支持的占位符有:
|
||||
* `${uuid-with-dash}`:带有`-`的 UUID
|
||||
* `${uuid-no-dash}`:不带`-`的 UUID
|
||||
* `${timestamp-sec}`:秒时间戳(10位时间戳)
|
||||
* `${timestamp-ms}`:毫秒时间戳(13位时间戳)
|
||||
* `${year}`:年份
|
||||
* `${month}`:月份(两位数)
|
||||
* `${day}`:日期(两位数)
|
||||
* `${weekday}`:星期几,1-7
|
||||
* `${hour}`:小时(24小时制,两位数)
|
||||
* `${minute}`:分钟(两位数)
|
||||
* `${second}`:秒(两位数)
|
||||
* `${millisecond}`:毫秒(三位数)
|
||||
* `${random-alphabetic:X}`:随机的小写英文字母,长度为`X`,例如`${random-alphabetic:5}`会生成`abcde`。
|
||||
* `${random-num:X}`:随机的数字,长度为`X`,例如`${random-num:5}`会生成`12345`。
|
||||
* `${random-alphanumeric:X}`:随机的小写英文字母和数字,长度为`X`,例如`${random-alphanumeric:5}`会生成`abc12`。
|
||||
|
||||
> **示例**:<br/>
|
||||
> * `${year}/${month}/${day}/${random-alphabetic:1}`会放在`2023/12/01/a`。<br/>
|
||||
> * `halo/${uuid-no-dash}`会放在`halo/123E4567E89B12D3A456426614174000`。
|
||||
|
||||
### 上传时重命名文件方式
|
||||
* **保留原文件名:** 使用上传时的文件名。
|
||||
* **自定义:** 使用`自定义文件名模板`中填写的模板,上传时替换相应占位符作后作为文件名。
|
||||
* **使用 UUID:** 上传时会自动重命名为随机的 UUID。
|
||||
* **使用毫秒时间戳:** 上传时会自动重命名为毫秒时间戳(13位时间戳)。
|
||||
* **使使用原文件名 + 随机字母:** 上传时会自动重命名为原文件名 + 随机的小写英文字母,长度请在`随机字母长度`中设置。
|
||||
* **使用日期 + 随机字母:** 上传时会自动重命名为日期 + 随机的小写英文字母,例如 `2023-12-01-abcdefgh.png`。
|
||||
* **使用日期时间 + 随机字母:** 上传时会自动重命名为日期时间 + 随机的小写英文字母,例如 `2023-12-01T09:30:01-abcdef.png`。
|
||||
* **使用随机字母:** 上传时会自动重命名为随机的小写英文字母,长度请在`随机字母长度`中设置。
|
||||
|
||||
### 随机字母长度
|
||||
|
||||
仅当`上传时重命名文件方式`为`使用原文件名 + 随机字母`或`使用日期 + 随机字母`或`使用日期时间 + 随机字母`或`使用随机字母`时出现,用于设置随机字母的长度。
|
||||
|
||||
### 自定义文件名模板
|
||||
|
||||
仅当`上传时重命名文件方式`为`自定义`时出现,用于设置自定义文件名模板。
|
||||
|
||||
支持的占位符有:
|
||||
* `${origin-filename}`:原文件名
|
||||
* `${uuid-with-dash}`:带有`-`的 UUID
|
||||
* `${uuid-no-dash}`:不带`-`的 UUID
|
||||
* `${timestamp-sec}`:秒时间戳(10位时间戳)
|
||||
* `${timestamp-ms}`:毫秒时间戳(13位时间戳)
|
||||
* `${year}`:年份
|
||||
* `${month}`:月份(两位数)
|
||||
* `${day}`:日期(两位数)
|
||||
* `${weekday}`:星期几,1-7
|
||||
* `${hour}`:小时(24小时制,两位数)
|
||||
* `${minute}`:分钟(两位数)
|
||||
* `${second}`:秒(两位数)
|
||||
* `${millisecond}`:毫秒(三位数)
|
||||
* `${random-alphabetic:X}`:随机的小写英文字母,长度为`X`,例如`${random-alphabetic:5}`会生成`abcde`。
|
||||
* `${random-num:X}`:随机的数字,长度为`X`,例如`${random-num:5}`会生成`12345`。
|
||||
* `${random-alphanumeric:X}`:随机的小写英文字母和数字,长度为`X`,例如`${random-alphanumeric:5}`会生成`abc12`。
|
||||
|
||||
> **示例**:<br/>
|
||||
> 当原始文件名为`image.png`时<br/>
|
||||
> * `${origin-filename}-${uuid-with-dash}`会生成`image-123E4567-E89B-12D3-A456-426614174000.png`。<br/>
|
||||
> * `${year}-${month}-${day}T${hour}:${minute}:${second}-${random-alphanumeric:5}`会生成`2023-12-01T09:30:01-abc12.png`。<br/>
|
||||
> * `${uuid-no-dash}_file_${random-alphabetic:5}`会生成`123E4567E89B12D3A456426614174000_file_abcde.png`。<br/>
|
||||
> * `halo_${origin-filename}_${random-num:3}`会生成`halo_image_123.png`。
|
||||
|
||||
### 重复文件名处理方式
|
||||
|
||||
* **加随机字母数字后缀:** 如遇重名,会在文件名后加上4位的随机字母数字后缀,例如`image.png`会变成`image_abc1.png`。
|
||||
* **加随机字母后缀:** 如遇重名,会在文件名后加上4位的随机字母后缀,例如`image.png`会变成`image_abcd.png`。
|
||||
* **报错不上传** 如遇重名,会放弃上传,并在用户界面提示 Duplicate filename 错误。
|
||||
|
||||
## 部分对象存储服务商兼容性
|
||||
|
||||
|
@@ -16,7 +16,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform('run.halo.tools.platform:plugin:2.10.0-SNAPSHOT')
|
||||
implementation platform('run.halo.tools.platform:plugin:2.14.0-SNAPSHOT')
|
||||
compileOnly 'run.halo.app:api'
|
||||
|
||||
implementation platform('software.amazon.awssdk:bom:2.19.8')
|
||||
@@ -36,7 +36,7 @@ configurations.runtimeClasspath {
|
||||
|
||||
|
||||
halo {
|
||||
version = '2.10.1'
|
||||
version = '2.14.0'
|
||||
}
|
||||
|
||||
haloPlugin {
|
||||
@@ -59,6 +59,10 @@ task buildFrontend(type: PnpmTask) {
|
||||
args = ['build']
|
||||
}
|
||||
|
||||
tasks.named('buildFrontend') {
|
||||
dependsOn 'pnpmInstall'
|
||||
}
|
||||
|
||||
build {
|
||||
// build frontend before build
|
||||
tasks.getByName('compileJava').dependsOn('buildFrontend')
|
||||
|
@@ -11,9 +11,9 @@
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@halo-dev/api-client": "^2.10.0",
|
||||
"@halo-dev/components": "^1.9.0",
|
||||
"@halo-dev/console-shared": "^2.10.0",
|
||||
"@halo-dev/api-client": "^2.11.0",
|
||||
"@halo-dev/components": "^1.10.0",
|
||||
"@halo-dev/console-shared": "^2.11.0",
|
||||
"@tanstack/vue-query": "4.29.1",
|
||||
"axios": "^1.4.0",
|
||||
"canvas-confetti": "^1.6.0",
|
||||
@@ -21,6 +21,7 @@
|
||||
"vue": "^3.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@halo-dev/ui-plugin-bundler-kit": "^2.12.0",
|
||||
"@iconify/json": "^2.2.18",
|
||||
"@rushstack/eslint-patch": "^1.2.0",
|
||||
"@types/canvas-confetti": "^1.6.0",
|
||||
|
38
console/pnpm-lock.yaml
generated
38
console/pnpm-lock.yaml
generated
@@ -6,14 +6,14 @@ settings:
|
||||
|
||||
dependencies:
|
||||
'@halo-dev/api-client':
|
||||
specifier: ^2.10.0
|
||||
version: 2.10.0
|
||||
specifier: ^2.11.0
|
||||
version: 2.11.0
|
||||
'@halo-dev/components':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(vue-router@4.2.5)(vue@3.3.7)
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.0(vue-router@4.2.5)(vue@3.3.7)
|
||||
'@halo-dev/console-shared':
|
||||
specifier: ^2.10.0
|
||||
version: 2.10.0(vue-router@4.2.5)(vue@3.3.7)
|
||||
specifier: ^2.11.0
|
||||
version: 2.11.0(vue-router@4.2.5)(vue@3.3.7)
|
||||
'@tanstack/vue-query':
|
||||
specifier: 4.29.1
|
||||
version: 4.29.1(vue@3.3.7)
|
||||
@@ -31,6 +31,9 @@ dependencies:
|
||||
version: 3.3.7(typescript@4.7.4)
|
||||
|
||||
devDependencies:
|
||||
'@halo-dev/ui-plugin-bundler-kit':
|
||||
specifier: ^2.12.0
|
||||
version: 2.12.0(vite@3.1.8)
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.18
|
||||
version: 2.2.18
|
||||
@@ -554,12 +557,12 @@ packages:
|
||||
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
|
||||
dev: false
|
||||
|
||||
/@halo-dev/api-client@2.10.0:
|
||||
resolution: {integrity: sha512-c1fgp+xzE1gQ7O36miWU2Oz+Uh1G4Hqs3clxLSxBD7vuvktgndZGZblNDi5morHB9UkiauAIQP0+PH9L9jwBfA==}
|
||||
/@halo-dev/api-client@2.11.0:
|
||||
resolution: {integrity: sha512-i3PFETsPdHYnTgk3jORu00t43/rCesmqpdZg38/Hq2AdgxhPkE7rghYGdoZRLdanvVC0HM1Axn18Zd7kdizxVA==}
|
||||
dev: false
|
||||
|
||||
/@halo-dev/components@1.9.0(vue-router@4.2.5)(vue@3.3.7):
|
||||
resolution: {integrity: sha512-Rc6zK7Uno+kLqvmZkv/NIv1EUVcFzS5FR7s0ai3LHbwybFF2BNul9HEEslS3Z5a9C0M0arnvkjpUlyBmrWbfRQ==}
|
||||
/@halo-dev/components@1.10.0(vue-router@4.2.5)(vue@3.3.7):
|
||||
resolution: {integrity: sha512-Qg7JEkuVyTAqTjuLJHQifhMGl180yZTOX2cSueAssFCuyGZtVCcN/o5FmDtrcw8UXoV8vRwxvpixgjxFwlf/4Q==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.4
|
||||
vue-router: ^4.2.4
|
||||
@@ -571,17 +574,26 @@ packages:
|
||||
- '@nuxt/kit'
|
||||
dev: false
|
||||
|
||||
/@halo-dev/console-shared@2.10.0(vue-router@4.2.5)(vue@3.3.7):
|
||||
resolution: {integrity: sha512-c1Ol/xIU2DZk1MWhxtuk1csD90DPvCLByQyGenLgLPX1kVe6QCodWgPG4Uz0WOE1s+rZ2W6XDV0DBXrIHCYBxg==}
|
||||
/@halo-dev/console-shared@2.11.0(vue-router@4.2.5)(vue@3.3.7):
|
||||
resolution: {integrity: sha512-XDyoHsueVgQOvMTDm4Fx3qKzCjXd7bI9eC0DFuw3w85Y3LQeHgrJfbXRlMRCTTZhe3kgpBOra4JjByyFFWa/Cw==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.4
|
||||
vue-router: ^4.2.4
|
||||
dependencies:
|
||||
'@halo-dev/api-client': 2.10.0
|
||||
'@halo-dev/api-client': 2.11.0
|
||||
vue: 3.3.7(typescript@4.7.4)
|
||||
vue-router: 4.2.5(vue@3.3.7)
|
||||
dev: false
|
||||
|
||||
/@halo-dev/ui-plugin-bundler-kit@2.12.0(vite@3.1.8):
|
||||
resolution: {integrity: sha512-3558qzH5RN9pB2j0ZonuIxX3cw8lh870cWpPPHjkDxTIjKt+aO5tjKhcqKlFL853jdx9nHIIS+nMDCeqjejpxw==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
peerDependencies:
|
||||
vite: ^4.0.0 || ^5.0.0
|
||||
dependencies:
|
||||
vite: 3.1.8(sass@1.58.0)
|
||||
dev: true
|
||||
|
||||
/@humanwhocodes/config-array@0.11.6:
|
||||
resolution: {integrity: sha512-jJr+hPTJYKyDILJfhNSHsjiwXYf26Flsz8DvNndOsHs5pwSnpGUEy8yzF0JYhCEvTDdV2vuOK5tt8BVhwO5/hg==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
|
@@ -10,6 +10,7 @@ export function getApisS3OsHaloRunV1Alpha1ObjectsByPolicyName(params: GetApisS3O
|
||||
continuationObject: params.continuationObject,
|
||||
pageSize: params.pageSize,
|
||||
unlinked: params.unlinked,
|
||||
filePrefix: params.filePrefix,
|
||||
};
|
||||
return request.get<DeepRequired<S3ListResult>>(`/apis/s3os.halo.run/v1alpha1/objects/${params.policyName}`, {
|
||||
params: paramsInput,
|
||||
@@ -22,4 +23,5 @@ interface GetApisS3OsHaloRunV1Alpha1ObjectsByPolicyNameParams {
|
||||
continuationObject?: any;
|
||||
pageSize: any;
|
||||
unlinked?: any;
|
||||
filePrefix?: any;
|
||||
}
|
||||
|
@@ -1,27 +1,35 @@
|
||||
import type {Attachment} from "@halo-dev/api-client";
|
||||
import type {PluginTab} from "@halo-dev/console-shared";
|
||||
import {definePlugin} from "@halo-dev/console-shared";
|
||||
import type { Attachment } from "@halo-dev/api-client";
|
||||
import { definePlugin } from "@halo-dev/console-shared";
|
||||
import S3Link from "./views/S3Link.vue";
|
||||
import S3Unlink from "./views/S3Unlink.vue"
|
||||
import type {Ref} from "vue";
|
||||
import {markRaw} from "vue";
|
||||
import S3Unlink from "./views/S3Unlink.vue";
|
||||
import type { Ref } from "vue";
|
||||
import { markRaw } from "vue";
|
||||
import CarbonFolderDetailsReference from "~icons/carbon/folder-details-reference";
|
||||
|
||||
export default definePlugin({
|
||||
components: {},
|
||||
routes: [],
|
||||
extensionPoints: {
|
||||
"plugin:self:tabs:create": (): PluginTab[] => {
|
||||
return [
|
||||
routes: [
|
||||
{
|
||||
id: "s3-link",
|
||||
label: "关联S3文件",
|
||||
// @ts-ignore
|
||||
component: markRaw(S3Link),
|
||||
permissions: ["plugin:s3os:link"]
|
||||
parentName: "ToolsRoot",
|
||||
route: {
|
||||
path: "s3-link",
|
||||
name: "S3Link",
|
||||
component: S3Link,
|
||||
meta: {
|
||||
title: "S3 关联",
|
||||
description: "提供将 S3 存储桶中的文件关联到 Halo 中的功能。",
|
||||
searchable: true,
|
||||
permissions: ["plugin:s3os:link"],
|
||||
menu: {
|
||||
name: "S3 关联",
|
||||
icon: markRaw(CarbonFolderDetailsReference),
|
||||
priority: 0,
|
||||
},
|
||||
];
|
||||
},
|
||||
// @ts-ignore
|
||||
},
|
||||
},
|
||||
],
|
||||
extensionPoints: {
|
||||
"attachment:list-item:operation:create": (attachment: Ref<Attachment>) => {
|
||||
return [
|
||||
{
|
||||
@@ -29,10 +37,14 @@ export default definePlugin({
|
||||
component: markRaw(S3Unlink),
|
||||
permissions: ["plugin:s3os:unlink"],
|
||||
props: {
|
||||
"attachment": attachment,
|
||||
attachment: attachment,
|
||||
},
|
||||
hidden: !(attachment.value.metadata.annotations
|
||||
&& attachment.value.metadata.annotations["s3os.plugin.halo.run/object-key"])
|
||||
hidden: !(
|
||||
attachment.value.metadata.annotations &&
|
||||
attachment.value.metadata.annotations[
|
||||
"s3os.plugin.halo.run/object-key"
|
||||
]
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
@@ -18,7 +18,9 @@ request.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const { status } = errorResponse;
|
||||
if (status !== 200) {
|
||||
if (status === 400) {
|
||||
Toast.error(errorResponse.data.detail);
|
||||
} else if (status !== 200) {
|
||||
Toast.error("status: " + status);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
@@ -14,6 +14,7 @@ import {
|
||||
VTag,
|
||||
} from "@halo-dev/components";
|
||||
import CarbonFolderDetailsReference from "~icons/carbon/folder-details-reference";
|
||||
import IconErrorWarning from "~icons/ri/error-warning-line";
|
||||
import {computed, onMounted, ref, watch} from "vue";
|
||||
import {
|
||||
getApisS3OsHaloRunV1Alpha1ObjectsByPolicyName,
|
||||
@@ -27,10 +28,14 @@ const policyName = ref<string>("");
|
||||
const page = ref(1);
|
||||
const size = ref(50);
|
||||
const policyOptions = ref<{ label: string; value: string; attrs: any }[]>([{
|
||||
label: "请选择策略",
|
||||
label: "请选择存储策略",
|
||||
value: "",
|
||||
attrs: {disabled: true}
|
||||
}]);
|
||||
// update when fetch first page
|
||||
const filePrefix = ref<string>("");
|
||||
// update when user input
|
||||
const filePrefixBind = ref<string>("");
|
||||
const s3Objects = ref<S3ListResult>({
|
||||
objects: [],
|
||||
hasMore: false,
|
||||
@@ -58,15 +63,15 @@ const selectedLinkedStatusItem = ref<boolean | undefined>(linkedStatusItems[0].v
|
||||
|
||||
const emptyTips = computed(() => {
|
||||
if (isFetchingPolicies.value) {
|
||||
return "正在加载策略";
|
||||
return "正在加载存储策略";
|
||||
} else {
|
||||
if (policyOptions.value.length <= 1) {
|
||||
return "没有可用的策略,请前往【附件】添加S3策略";
|
||||
return "没有可用的存储策略,请前往【附件】添加S3存储策略";
|
||||
} else {
|
||||
if (!policyName.value) {
|
||||
return "请在左上方选择策略";
|
||||
return "请在左上方选择存储策略";
|
||||
} else {
|
||||
return "该策略的 桶/文件夹 下没有文件";
|
||||
return "该存储策略的 桶/文件夹 下没有文件";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +97,7 @@ const fetchPolicies = async () => {
|
||||
const policiesData = await getApisS3OsHaloRunV1Alpha1PoliciesS3();
|
||||
if (policiesData.status == 200) {
|
||||
policyOptions.value = [{
|
||||
label: "请选择策略",
|
||||
label: "请选择存储策略",
|
||||
value: "",
|
||||
attrs: {disabled: true}
|
||||
}];
|
||||
@@ -139,6 +144,8 @@ const clearTokenAndObject = () => {
|
||||
s3Objects.value.nextContinuationObject = "";
|
||||
};
|
||||
|
||||
// filePrefix will not be updated from user input
|
||||
// if you want to update filePrefix, please call `handleFirstPage`
|
||||
const fetchObjects = async () => {
|
||||
if (!policyName.value) {
|
||||
return;
|
||||
@@ -152,6 +159,7 @@ const fetchObjects = async () => {
|
||||
continuationToken: s3Objects.value.currentToken,
|
||||
continuationObject: s3Objects.value.currentContinuationObject,
|
||||
unlinked: selectedLinkedStatusItem.value,
|
||||
filePrefix: filePrefix.value
|
||||
});
|
||||
if (objectsData.status == 200) {
|
||||
s3Objects.value = objectsData.data;
|
||||
@@ -222,6 +230,7 @@ const handleFirstPage = () => {
|
||||
isFetching.value = true;
|
||||
page.value = 1;
|
||||
clearTokenAndObject();
|
||||
filePrefix.value = filePrefixBind.value;
|
||||
fetchObjects();
|
||||
};
|
||||
|
||||
@@ -255,18 +264,26 @@ const handleModalClose = () => {
|
||||
<div class="flex w-full flex-1 items-center sm:w-auto">
|
||||
<div
|
||||
v-if="!selectedFiles.length"
|
||||
class="flex items-center gap-2"
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
策略:
|
||||
<span class="whitespace-nowrap">存储策略:</span>
|
||||
<FormKit
|
||||
id="policyChoose"
|
||||
outer-class="!p-0 w-48"
|
||||
outer-class="!p-0"
|
||||
style="min-width: 10rem;"
|
||||
v-model="policyName"
|
||||
name="policyName"
|
||||
type="select"
|
||||
:options="policyOptions"
|
||||
@change="fetchObjects()"
|
||||
@change="handleFirstPage"
|
||||
></FormKit>
|
||||
<icon-error-warning v-if="!policyName" class="text-red-500"/>
|
||||
<SearchInput
|
||||
v-model="filePrefixBind"
|
||||
v-if="policyName"
|
||||
placeholder="请输入文件名前缀搜索"
|
||||
@update:modelValue="handleFirstPage"
|
||||
></SearchInput>
|
||||
</div>
|
||||
<VSpace v-else>
|
||||
<VButton type="primary" @click="handleLink">
|
||||
@@ -394,7 +411,7 @@ const handleModalClose = () => {
|
||||
<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"
|
||||
class="h-8 border outline-none rounded-base pr-10 border-solid px-2 text-gray-800 text-sm border-gray-300 page-size-select"
|
||||
@change="handleFirstPage"
|
||||
>
|
||||
<option
|
||||
@@ -448,5 +465,8 @@ const handleModalClose = () => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.page-size-select:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgba(var(--colors-primary),var(--tw-border-opacity));
|
||||
}
|
||||
</style>
|
||||
|
@@ -4,44 +4,18 @@ import { defineConfig } from "vite";
|
||||
import Vue from "@vitejs/plugin-vue";
|
||||
import VueJsx from "@vitejs/plugin-vue-jsx";
|
||||
import Icons from "unplugin-icons/vite";
|
||||
import { HaloUIPluginBundlerKit } from "@halo-dev/ui-plugin-bundler-kit";
|
||||
|
||||
const pluginEntryName = "PluginS3ObjectStorage";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [Vue(), VueJsx(), Icons({ compiler: "vue3" })],
|
||||
plugins: [
|
||||
Vue(),
|
||||
VueJsx(),
|
||||
Icons({ compiler: "vue3" }),
|
||||
HaloUIPluginBundlerKit(),
|
||||
],
|
||||
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.7.0-SNAPSHOT
|
||||
version=1.9.0-SNAPSHOT
|
||||
|
@@ -1,95 +1,130 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import static run.halo.s3os.S3OsProperties.DuplicateFilenameHandling;
|
||||
import static run.halo.s3os.S3OsProperties.RandomFilenameMode;
|
||||
|
||||
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;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
|
||||
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;
|
||||
};
|
||||
/**
|
||||
* Replace placeholders in filename. No duplicate handling.
|
||||
*
|
||||
* @param filename filename
|
||||
* @param mode random filename mode
|
||||
* @param randomStringLength random string length,when mode is withString or string
|
||||
* @param customTemplate custom template,when mode is custom
|
||||
* @return replaced filename
|
||||
*/
|
||||
public static String replaceFilename(String filename, RandomFilenameMode mode,
|
||||
Integer randomStringLength, String customTemplate) {
|
||||
var extension = Files.getFileExtension(filename);
|
||||
var filenameWithoutExtension = Files.getNameWithoutExtension(filename);
|
||||
var replaced = replaceFilenameByMode(filenameWithoutExtension, mode, randomStringLength,
|
||||
customTemplate);
|
||||
return replaced + (StringUtils.isBlank(extension) ? "" : "." + extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append random string after file name.
|
||||
* Replace placeholders in filename with duplicate handling.
|
||||
* <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.
|
||||
* @param filename filename
|
||||
* @param mode random filename mode
|
||||
* @param randomStringLength random string length,when mode is withString or string
|
||||
* @param customTemplate custom template,when mode is custom
|
||||
* @param handling duplicate filename handling
|
||||
* @return replaced filename
|
||||
*/
|
||||
public static String randomFilenameWithString(String filename, Integer length) {
|
||||
String random = RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||
return randomFilename(filename, random, true);
|
||||
public static String replaceFilenameWithDuplicateHandling(String filename,
|
||||
RandomFilenameMode mode,
|
||||
Integer randomStringLength,
|
||||
String customTemplate,
|
||||
DuplicateFilenameHandling handling) {
|
||||
var extension = Files.getFileExtension(filename);
|
||||
var filenameWithoutExtension = Files.getNameWithoutExtension(filename);
|
||||
var replaced =
|
||||
replaceFilenameByMode(filenameWithoutExtension, mode, randomStringLength,
|
||||
customTemplate);
|
||||
var suffix = getDuplicateFilenameSuffix(handling);
|
||||
return replaced + (StringUtils.isBlank(replaced) ? "" : "-") + suffix
|
||||
+ (StringUtils.isBlank(extension) ? "" : "." + extension);
|
||||
}
|
||||
|
||||
private static String randomDateWithString(String filename, Integer length) {
|
||||
String random = LocalDate.now() + "-" + RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||
return randomFilename(filename, random, false);
|
||||
private static String getDuplicateFilenameSuffix(
|
||||
S3OsProperties.DuplicateFilenameHandling duplicateFilenameHandling) {
|
||||
if (duplicateFilenameHandling == null) {
|
||||
return RandomStringUtils.randomAlphabetic(4).toLowerCase();
|
||||
}
|
||||
return switch (duplicateFilenameHandling) {
|
||||
case randomAlphabetic -> RandomStringUtils.randomAlphabetic(4).toLowerCase();
|
||||
case exception -> throw new ServerWebInputException("Duplicate filename");
|
||||
// include "randomAlphanumeric" mode
|
||||
default -> RandomStringUtils.randomAlphanumeric(4).toLowerCase();
|
||||
};
|
||||
}
|
||||
|
||||
private static String randomDatetimeWithString(String filename, Integer length) {
|
||||
String random = LocalDateTime.now() + "-" + RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||
return randomFilename(filename, random, false);
|
||||
private static String replaceFilenameByMode(String filenameWithoutExtension,
|
||||
S3OsProperties.RandomFilenameMode mode,
|
||||
Integer randomStringLength,
|
||||
String customTemplate) {
|
||||
if (mode == null) {
|
||||
return filenameWithoutExtension;
|
||||
}
|
||||
// default length is 8
|
||||
Integer length = randomStringLength == null ? 8 : randomStringLength;
|
||||
|
||||
private static String randomString(String filename, Integer length) {
|
||||
String random = RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||
return randomFilename(filename, random, false);
|
||||
return switch (mode) {
|
||||
case custom -> {
|
||||
if (StringUtils.isBlank(customTemplate)) {
|
||||
yield filenameWithoutExtension;
|
||||
}
|
||||
yield PlaceholderReplacer.replacePlaceholders(customTemplate,
|
||||
filenameWithoutExtension);
|
||||
}
|
||||
case uuid -> PlaceholderReplacer.replacePlaceholders("${uuid-with-dash}",
|
||||
filenameWithoutExtension);
|
||||
case timestampMs -> PlaceholderReplacer.replacePlaceholders("${timestamp-ms}",
|
||||
filenameWithoutExtension);
|
||||
case dateWithString -> {
|
||||
String dateWithStringTemplate =
|
||||
String.format("${year}-${month}-${day}-${random-alphabetic:%d}", length);
|
||||
yield PlaceholderReplacer.replacePlaceholders(dateWithStringTemplate,
|
||||
filenameWithoutExtension);
|
||||
}
|
||||
case datetimeWithString -> {
|
||||
String datetimeWithStringTemplate = String.format(
|
||||
"${year}-${month}-${day}T${hour}:${minute}:${second}-${random-alphabetic:%d}",
|
||||
length);
|
||||
yield PlaceholderReplacer.replacePlaceholders(datetimeWithStringTemplate,
|
||||
filenameWithoutExtension);
|
||||
}
|
||||
case withString -> {
|
||||
String withStringTemplate =
|
||||
String.format("${origin-filename}-${random-alphabetic:%d}", length);
|
||||
yield PlaceholderReplacer.replacePlaceholders(withStringTemplate,
|
||||
filenameWithoutExtension);
|
||||
}
|
||||
case string -> {
|
||||
String stringTemplate = String.format("${random-alphabetic:%d}", length);
|
||||
yield PlaceholderReplacer.replacePlaceholders(stringTemplate,
|
||||
filenameWithoutExtension);
|
||||
}
|
||||
default ->
|
||||
// include "none" mode
|
||||
filenameWithoutExtension;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,23 +1,11 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
@UtilityClass
|
||||
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())
|
||||
}
|
||||
);
|
||||
public static String getFilePathByPlaceholder(String filePath) {
|
||||
return PlaceholderReplacer.replacePlaceholders(filePath, null);
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,12 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import java.util.Set;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@RequiredArgsConstructor
|
||||
public class LinkRequest {
|
||||
private String policyName;
|
||||
private List<String> objectKeys;
|
||||
private Set<String> objectKeys;
|
||||
}
|
184
src/main/java/run/halo/s3os/PlaceholderReplacer.java
Normal file
184
src/main/java/run/halo/s3os/PlaceholderReplacer.java
Normal file
@@ -0,0 +1,184 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import org.apache.commons.lang3.RandomStringUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.util.PropertyPlaceholderHelper;
|
||||
|
||||
@UtilityClass
|
||||
public class PlaceholderReplacer {
|
||||
record PlaceholderFunctionInput(String[] placeholderParams,
|
||||
Map<String, String> reusableParams) {
|
||||
}
|
||||
private static final PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}");
|
||||
|
||||
private static final Map<String, Function<PlaceholderFunctionInput, String>>
|
||||
placeholderFunctions = new HashMap<>();
|
||||
|
||||
static {
|
||||
initializePlaceholderFunctions();
|
||||
}
|
||||
|
||||
private static void initializePlaceholderFunctions() {
|
||||
placeholderFunctions.put("origin-filename", input -> input.reusableParams.get("filename"));
|
||||
placeholderFunctions.put("uuid-with-dash", input -> generateUUIDWithDash());
|
||||
placeholderFunctions.put("uuid-no-dash", input -> generateUUIDWithoutDash());
|
||||
placeholderFunctions.put("timestamp-sec",
|
||||
input -> currentSecondsTimestamp(input.reusableParams));
|
||||
placeholderFunctions.put("timestamp-ms",
|
||||
input -> currentMillisecondsTimestamp(input.reusableParams));
|
||||
placeholderFunctions.put("year", input -> currentYear(input.reusableParams));
|
||||
placeholderFunctions.put("month", input -> currentMonth(input.reusableParams));
|
||||
placeholderFunctions.put("day", input -> currentDay(input.reusableParams));
|
||||
placeholderFunctions.put("weekday", input -> currentWeekday(input.reusableParams));
|
||||
placeholderFunctions.put("hour", input -> currentHour(input.reusableParams));
|
||||
placeholderFunctions.put("minute", input -> currentMinute(input.reusableParams));
|
||||
placeholderFunctions.put("second", input -> currentSecond(input.reusableParams));
|
||||
placeholderFunctions.put("millisecond", input -> currentMillisecond(input.reusableParams));
|
||||
placeholderFunctions.put("random-alphabetic",
|
||||
input -> generateRandomLetter(input.placeholderParams));
|
||||
placeholderFunctions.put("random-num",
|
||||
input -> generateRandomNumber(input.placeholderParams));
|
||||
placeholderFunctions.put("random-alphanumeric",
|
||||
input -> generateRandomAlphanumeric(input.placeholderParams));
|
||||
}
|
||||
|
||||
private static String generateRandomAlphanumeric(String[] placeholderParams) {
|
||||
try {
|
||||
int length = Integer.parseInt(placeholderParams[0]);
|
||||
return RandomStringUtils.randomAlphanumeric(length > 0 ? length : 8).toLowerCase();
|
||||
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
|
||||
return RandomStringUtils.randomAlphanumeric(8).toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
private static String generateRandomNumber(String[] placeholderParams) {
|
||||
try {
|
||||
int length = Integer.parseInt(placeholderParams[0]);
|
||||
return RandomStringUtils.randomNumeric(length > 0 ? length : 8);
|
||||
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
|
||||
return RandomStringUtils.randomNumeric(8);
|
||||
}
|
||||
}
|
||||
|
||||
private static String currentMillisecond(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.format("%03d", time.getNano() / 1000000);
|
||||
}
|
||||
|
||||
private static String currentSecond(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.format("%02d", time.getSecond());
|
||||
}
|
||||
|
||||
private static String currentMinute(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.format("%02d", time.getMinute());
|
||||
}
|
||||
|
||||
private static String currentHour(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.format("%02d", time.getHour());
|
||||
}
|
||||
|
||||
private static String currentWeekday(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.valueOf(time.getDayOfWeek().getValue());
|
||||
}
|
||||
|
||||
private static String currentDay(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.format("%02d", time.getDayOfMonth());
|
||||
}
|
||||
|
||||
private static String currentMonth(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.format("%02d", time.getMonthValue());
|
||||
}
|
||||
|
||||
private static String currentYear(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.valueOf(time.getYear());
|
||||
}
|
||||
|
||||
private static String currentMillisecondsTimestamp(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.valueOf(
|
||||
time.toInstant(ZoneOffset.systemDefault().getRules().getOffset(time)).toEpochMilli());
|
||||
}
|
||||
|
||||
private static String currentSecondsTimestamp(Map<String, String> reusableParams) {
|
||||
LocalDateTime time = LocalDateTime.parse(reusableParams.get("time"));
|
||||
return String.valueOf(
|
||||
time.toInstant(ZoneOffset.systemDefault().getRules().getOffset(time)).getEpochSecond());
|
||||
}
|
||||
|
||||
|
||||
private static String generateRandomLetter(String[] placeholderParams) {
|
||||
try {
|
||||
int length = Integer.parseInt(placeholderParams[0]);
|
||||
return RandomStringUtils.randomAlphabetic(length > 0 ? length : 8).toLowerCase();
|
||||
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
|
||||
return RandomStringUtils.randomAlphabetic(8).toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
private static String generateUUIDWithoutDash() {
|
||||
return UUID.randomUUID().toString().replace("-", "").toUpperCase();
|
||||
}
|
||||
|
||||
private static String generateUUIDWithDash() {
|
||||
return UUID.randomUUID().toString().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace placeholders in the template with the provided filename.
|
||||
*
|
||||
* @param template The template to replace
|
||||
* @param filename The filename without extension
|
||||
* @return The replaced string
|
||||
*/
|
||||
public static String replacePlaceholders(String template, String filename) {
|
||||
if (StringUtils.isBlank(template)) {
|
||||
return filename;
|
||||
}
|
||||
Map<String, String> reusableParams = new HashMap<>();
|
||||
|
||||
reusableParams.put("filename", filename);
|
||||
reusableParams.put("time", LocalDateTime.now().toString());
|
||||
|
||||
return helper.replacePlaceholders(template, placeholder -> getPlaceholderValue(placeholder, reusableParams));
|
||||
}
|
||||
|
||||
private static String getPlaceholderValue(String placeholderWithParam,
|
||||
Map<String, String> reusableParams) {
|
||||
String[] parts = placeholderWithParam.split(":");
|
||||
String placeholder = parts[0];
|
||||
|
||||
String[] placeholderParams;
|
||||
if (parts.length > 1) {
|
||||
placeholderParams = new String[parts.length - 1];
|
||||
System.arraycopy(parts, 1, placeholderParams, 0, parts.length - 1);
|
||||
} else {
|
||||
placeholderParams = new String[0];
|
||||
}
|
||||
|
||||
Function<PlaceholderFunctionInput, String> placeholderFunction =
|
||||
placeholderFunctions.get(placeholder);
|
||||
if (placeholderFunction != null) {
|
||||
// Call the placeholder function with the provided map
|
||||
return placeholderFunction.apply(
|
||||
new PlaceholderFunctionInput(placeholderParams, reusableParams));
|
||||
} else {
|
||||
// If placeholder not found, return null to keep the original placeholder string
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,123 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import static run.halo.s3os.S3OsAttachmentHandler.MULTIPART_MIN_PART_SIZE;
|
||||
import static run.halo.s3os.S3OsAttachmentHandler.checkResult;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.utils.PathUtils;
|
||||
import run.halo.app.plugin.ApiVersion;
|
||||
import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
|
||||
import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
|
||||
import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.utils.SdkAutoCloseable;
|
||||
|
||||
@ApiVersion("s3os.halo.run/v1alpha1")
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PolicyConfigValidationController {
|
||||
private final S3OsAttachmentHandler handler;
|
||||
|
||||
@PostMapping("/policies/s3/validation")
|
||||
public Mono<Void> validatePolicyConfig(@RequestBody S3OsProperties properties) {
|
||||
var filename = "halo-s3-plugin-test-file-" + System.currentTimeMillis() + ".jpg";
|
||||
var content = readImage();
|
||||
return Mono.using(() -> handler.buildS3Client(properties),
|
||||
client -> {
|
||||
var uploadState =
|
||||
new S3OsAttachmentHandler.UploadState(properties, filename, false);
|
||||
|
||||
return handler.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(handler.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 handler.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()
|
||||
));
|
||||
})
|
||||
// check object metadata
|
||||
.doOnNext((response) -> {
|
||||
checkResult(response, "headObject");
|
||||
})
|
||||
// delete object
|
||||
.flatMap((response) -> Mono.just(client.deleteObject(
|
||||
software.amazon.awssdk.services.s3.model.DeleteObjectRequest.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.key(uploadState.objectKey)
|
||||
.build()
|
||||
)))
|
||||
.doOnNext((response) -> checkResult(response, "deleteObject"))
|
||||
.then();
|
||||
},
|
||||
SdkAutoCloseable::close)
|
||||
.onErrorMap(S3ExceptionHandler::map);
|
||||
}
|
||||
|
||||
private Flux<DataBuffer> readImage() {
|
||||
DefaultResourceLoader resourceLoader = new DefaultResourceLoader(this.getClass()
|
||||
.getClassLoader());
|
||||
String path = PathUtils.combinePath("validation.jpg");
|
||||
String simplifyPath = StringUtils.cleanPath(path);
|
||||
Resource resource = resourceLoader.getResource(simplifyPath);
|
||||
return DataBufferUtils.read(resource, new DefaultDataBufferFactory(), 1024);
|
||||
}
|
||||
}
|
42
src/main/java/run/halo/s3os/S3ExceptionHandler.java
Normal file
42
src/main/java/run/halo/s3os/S3ExceptionHandler.java
Normal file
@@ -0,0 +1,42 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class S3ExceptionHandler {
|
||||
|
||||
/**
|
||||
* Map user configuration caused S3 exception to ServerWebInputException
|
||||
* @param throwable Exception
|
||||
* @return ServerWebInputException or original exception
|
||||
*/
|
||||
public static Throwable map(Throwable throwable) {
|
||||
if (throwable instanceof S3Exception s3e) {
|
||||
log.error("S3Exception occurred", s3e);
|
||||
return new ServerWebInputException(String.format(
|
||||
"The object storage service returned an error status code %d. Please check the storage "
|
||||
+ "policy configuration and make sure your account and service are working properly.",
|
||||
s3e.statusCode()));
|
||||
}
|
||||
if (throwable instanceof SdkException sdke && sdke.getMessage() != null
|
||||
&& sdke.getMessage().contains("UnknownHostException")) {
|
||||
log.error("UnknownHostException occurred", sdke);
|
||||
return new ServerWebInputException(
|
||||
"Received an UnknownHostException, please check if the endpoint is entered correctly, "
|
||||
+ "especially for any spaces before or after the endpoint.");
|
||||
}
|
||||
if (throwable instanceof SdkException sdke && sdke.getMessage() != null
|
||||
&& sdke.getMessage().contains("Connect timed out")) {
|
||||
log.error("ConnectTimeoutException occurred", sdke);
|
||||
return new ServerWebInputException(
|
||||
"Received a ConnectTimeoutException, please check if the endpoint is entered correctly, "
|
||||
+ "and make sure your object storage service is working properly.");
|
||||
}
|
||||
return throwable;
|
||||
}
|
||||
}
|
@@ -1,30 +1,22 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
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() {
|
||||
@@ -36,50 +28,19 @@ public class S3LinkController {
|
||||
@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) {
|
||||
@RequestParam(name = "unlinked", required = false, defaultValue = "false") Boolean unlinked,
|
||||
@RequestParam(name = "filePrefix", required = false) String filePrefix) {
|
||||
if (unlinked) {
|
||||
return s3LinkService.listObjectsUnlinked(policyName, continuationToken,
|
||||
continuationObject, pageSize);
|
||||
continuationObject, pageSize, filePrefix);
|
||||
} else {
|
||||
return s3LinkService.listObjects(policyName, continuationToken, pageSize);
|
||||
return s3LinkService.listObjects(policyName, continuationToken, pageSize, filePrefix);
|
||||
}
|
||||
}
|
||||
|
||||
@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)));
|
||||
return s3LinkService.addAttachmentRecords(linkRequest.getPolicyName(),
|
||||
linkRequest.getObjectKeys());
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.attachment.Policy;
|
||||
@@ -9,10 +10,10 @@ public interface S3LinkService {
|
||||
Flux<Policy> listS3Policies();
|
||||
|
||||
Mono<S3ListResult> listObjects(String policyName, String continuationToken,
|
||||
Integer pageSize);
|
||||
Integer pageSize, String filePrefix);
|
||||
|
||||
Mono<LinkResult.LinkResultItem> addAttachmentRecord(String policyName, String objectKey);
|
||||
Mono<LinkResult> addAttachmentRecords(String policyName, Set<String> objectKeys);
|
||||
|
||||
Mono<S3ListResult> listObjectsUnlinked(String policyName, String continuationToken,
|
||||
String continuationObject, Integer pageSize);
|
||||
String continuationObject, Integer pageSize, String filePrefix);
|
||||
}
|
||||
|
@@ -1,46 +1,43 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import static run.halo.s3os.S3OsAttachmentHandler.OBJECT_KEY;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
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.ListOptions;
|
||||
import run.halo.app.extension.MetadataUtil;
|
||||
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 run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
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
|
||||
@@ -49,16 +46,21 @@ public class S3LinkServiceImpl implements S3LinkService {
|
||||
private final ReactiveExtensionClient client;
|
||||
private final S3OsAttachmentHandler handler;
|
||||
|
||||
/**
|
||||
* Map of linking file, used as a lock, key is policyName/objectKey, value is policyName/objectKey.
|
||||
*/
|
||||
private final Map<String, Object> linkingFile = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
@Override
|
||||
public Flux<Policy> listS3Policies() {
|
||||
return client.list(Policy.class, (policy) -> "s3os".equals(
|
||||
policy.getSpec().getTemplateName()), null);
|
||||
policy.getSpec().getTemplateName()), Comparator.naturalOrder());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<S3ListResult> listObjects(String policyName, String continuationToken,
|
||||
Integer pageSize) {
|
||||
Integer pageSize, String filePrefix) {
|
||||
return client.fetch(Policy.class, policyName)
|
||||
.flatMap((policy) -> {
|
||||
var configMapName = policy.getSpec().getConfigMapName();
|
||||
@@ -68,11 +70,11 @@ public class S3LinkServiceImpl implements S3LinkService {
|
||||
var properties = handler.getProperties(configMap);
|
||||
var finalLocation = FilePathUtils.getFilePathByPlaceholder(properties.getLocation());
|
||||
return Mono.using(() -> handler.buildS3Client(properties),
|
||||
// 执行 listObjects
|
||||
(s3Client) -> Mono.fromCallable(
|
||||
() -> s3Client.listObjectsV2(ListObjectsV2Request.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.prefix(StringUtils.isNotEmpty(finalLocation)
|
||||
? finalLocation + "/" : null)
|
||||
.prefix(buildPrefix(finalLocation, filePrefix))
|
||||
.delimiter("/")
|
||||
.maxKeys(pageSize)
|
||||
.continuationToken(StringUtils.isNotEmpty(continuationToken)
|
||||
@@ -81,13 +83,16 @@ public class S3LinkServiceImpl implements S3LinkService {
|
||||
S3Client::close)
|
||||
.flatMap(listObjectsV2Response -> {
|
||||
List<S3Object> contents = listObjectsV2Response.contents();
|
||||
// 过滤掉目录并转换为ObjectVo
|
||||
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)
|
||||
// 获取已经关联的附件并标记
|
||||
ListOptions listOptions = new ListOptions();
|
||||
listOptions.setFieldSelector(
|
||||
FieldSelector.of(QueryFactory.equal("spec.policyName", policyName)));
|
||||
return client.listAll(Attachment.class, listOptions, Sort.unsorted())
|
||||
.doOnNext(attachment -> {
|
||||
S3ListResult.ObjectVo objectVo =
|
||||
objectVos.get(attachment.getMetadata().getAnnotations()
|
||||
@@ -103,12 +108,66 @@ public class S3LinkServiceImpl implements S3LinkService {
|
||||
listObjectsV2Response.nextContinuationToken(),
|
||||
listObjectsV2Response.isTruncated()));
|
||||
});
|
||||
});
|
||||
})
|
||||
.onErrorMap(S3ExceptionHandler::map);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<LinkResult> addAttachmentRecords(String policyName, Set<String> objectKeys) {
|
||||
return getOperableObjectKeys(objectKeys, policyName)
|
||||
.flatMap(operableObjectKeys -> getExistingAttachments(objectKeys, policyName)
|
||||
.flatMap(existingAttachments -> getLinkResultItems(objectKeys, operableObjectKeys,
|
||||
existingAttachments, policyName)
|
||||
.collectList()
|
||||
.map(LinkResult::new)));
|
||||
}
|
||||
|
||||
private Mono<Set<String>> getOperableObjectKeys(Set<String> objectKeys, String policyName) {
|
||||
return Flux.fromIterable(objectKeys)
|
||||
.filter(objectKey ->
|
||||
linkingFile.put(policyName + "/" + objectKey, policyName + "/" + objectKey) == null)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Mono<Set<String>> getExistingAttachments(Set<String> objectKeys,
|
||||
String policyName) {
|
||||
ListOptions listOptions = new ListOptions();
|
||||
listOptions.setFieldSelector(
|
||||
FieldSelector.of(QueryFactory.equal("spec.policyName", policyName)));
|
||||
return client.listAll(Attachment.class, listOptions, Sort.unsorted())
|
||||
.filter(attachment -> StringUtils.isNotBlank(
|
||||
MetadataUtil.nullSafeAnnotations(attachment).get(S3OsAttachmentHandler.OBJECT_KEY))
|
||||
&& objectKeys.contains(
|
||||
MetadataUtil.nullSafeAnnotations(attachment).get(S3OsAttachmentHandler.OBJECT_KEY)))
|
||||
.map(attachment -> MetadataUtil.nullSafeAnnotations(attachment)
|
||||
.get(S3OsAttachmentHandler.OBJECT_KEY))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Flux<LinkResult.LinkResultItem> getLinkResultItems(Set<String> objectKeys,
|
||||
Set<String> operableObjectKeys,
|
||||
Set<String> existingAttachments,
|
||||
String policyName) {
|
||||
return Flux.fromIterable(objectKeys)
|
||||
.flatMap((objectKey) -> {
|
||||
if (operableObjectKeys.contains(objectKey) &&
|
||||
!existingAttachments.contains(objectKey)) {
|
||||
return addAttachmentRecord(policyName, objectKey)
|
||||
.onErrorResume((throwable) -> Mono.just(
|
||||
new LinkResult.LinkResultItem(objectKey, false,
|
||||
throwable.getMessage())));
|
||||
} else {
|
||||
return Mono.just(
|
||||
new LinkResult.LinkResultItem(objectKey, false, "附件库中已存在该对象"));
|
||||
}
|
||||
})
|
||||
.doFinally(signalType -> operableObjectKeys.forEach(
|
||||
objectKey -> linkingFile.remove(policyName + "/" + objectKey)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<S3ListResult> listObjectsUnlinked(String policyName, String continuationToken,
|
||||
String continuationObject, Integer pageSize) {
|
||||
String continuationObject, Integer pageSize, String filePrefix) {
|
||||
// TODO 优化成查一次数据库
|
||||
return Mono.defer(() -> {
|
||||
List<S3ListResult.ObjectVo> s3Objects = new ArrayList<>();
|
||||
@@ -117,7 +176,8 @@ public class S3LinkServiceImpl implements S3LinkService {
|
||||
|
||||
return Flux.defer(() -> Flux.just(
|
||||
new TokenState(null, currToken.get() == null ? "" : currToken.get())))
|
||||
.flatMap(tokenState -> listObjects(policyName, tokenState.nextToken, pageSize))
|
||||
.flatMap(tokenState -> listObjects(policyName, tokenState.nextToken,
|
||||
pageSize, filePrefix))
|
||||
.flatMap(s3ListResult -> {
|
||||
var filteredObjects = s3ListResult.getObjects();
|
||||
if (!continuationObjectMatched.get()) {
|
||||
@@ -155,14 +215,13 @@ public class S3LinkServiceImpl implements S3LinkService {
|
||||
.getKey() : null, tokenState.currToken,
|
||||
limitedObjects.size() == pageSize);
|
||||
});
|
||||
});
|
||||
})
|
||||
.onErrorMap(S3ExceptionHandler::map);
|
||||
}
|
||||
|
||||
record TokenState(String currToken, String nextToken) {
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<LinkResult.LinkResultItem> addAttachmentRecord(String policyName,
|
||||
String objectKey) {
|
||||
return authenticationConsumer(authentication -> client.fetch(Policy.class, policyName)
|
||||
@@ -200,6 +259,7 @@ public class S3LinkServiceImpl implements S3LinkService {
|
||||
.flatMap(client::create)
|
||||
.thenReturn(new LinkResult.LinkResultItem(objectKey, true, null));
|
||||
}))
|
||||
.onErrorMap(S3ExceptionHandler::map)
|
||||
.onErrorResume(throwable ->
|
||||
Mono.just(new LinkResult.LinkResultItem(objectKey, false, throwable.getMessage())));
|
||||
}
|
||||
@@ -212,4 +272,18 @@ public class S3LinkServiceImpl implements S3LinkService {
|
||||
.flatMap(func);
|
||||
}
|
||||
|
||||
String buildPrefix(String finalLocation, String filePrefix) {
|
||||
if (StringUtils.isBlank(finalLocation) && StringUtils.isBlank(filePrefix)) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (StringUtils.isNotBlank(finalLocation)) {
|
||||
sb.append(finalLocation).append("/");
|
||||
}
|
||||
if (StringUtils.isNotBlank(filePrefix)) {
|
||||
sb.append(filePrefix);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -82,7 +82,8 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
final var properties = getProperties(context.configMap());
|
||||
return upload(context, properties)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.map(objectDetail -> this.buildAttachment(properties, objectDetail));
|
||||
.map(objectDetail -> this.buildAttachment(properties, objectDetail))
|
||||
.onErrorMap(S3ExceptionHandler::map);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,6 +117,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
})
|
||||
.thenReturn(context);
|
||||
})
|
||||
.onErrorMap(S3ExceptionHandler::map)
|
||||
.map(DeleteContext::attachment);
|
||||
}
|
||||
|
||||
@@ -152,7 +154,8 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
}
|
||||
},
|
||||
SdkPresigner::close)
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.onErrorMap(S3ExceptionHandler::map);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -387,7 +390,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
SdkAutoCloseable::close);
|
||||
}
|
||||
|
||||
private Mono<UploadState> checkFileExistsAndRename(UploadState uploadState,
|
||||
Mono<UploadState> checkFileExistsAndRename(UploadState uploadState,
|
||||
S3Client s3client) {
|
||||
return Mono.defer(() -> {
|
||||
// deduplication of uploading files
|
||||
@@ -434,7 +437,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
}
|
||||
|
||||
|
||||
private Mono<CompletedPart> uploadPart(UploadState uploadState, ByteBuffer buffer,
|
||||
Mono<CompletedPart> uploadPart(UploadState uploadState, ByteBuffer buffer,
|
||||
S3Client s3client) {
|
||||
final int partNumber = ++uploadState.partCounter;
|
||||
return Mono.just(s3client.uploadPart(UploadPartRequest.builder()
|
||||
@@ -454,7 +457,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
});
|
||||
}
|
||||
|
||||
private static void checkResult(SdkResponse result, String operation) {
|
||||
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());
|
||||
@@ -462,7 +465,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private static ByteBuffer concatBuffers(List<DataBuffer> buffers) {
|
||||
static ByteBuffer concatBuffers(List<DataBuffer> buffers) {
|
||||
int partSize = 0;
|
||||
for (DataBuffer b : buffers) {
|
||||
partSize += b.readableByteCount();
|
||||
@@ -507,8 +510,9 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
this.originalFileName = fileName;
|
||||
|
||||
if (needRandomJudge) {
|
||||
fileName = FileNameUtils.getRandomFilename(fileName,
|
||||
properties.getRandomStringLength(), properties.getRandomFilenameMode());
|
||||
fileName =
|
||||
FileNameUtils.replaceFilename(fileName, properties.getRandomFilenameMode(),
|
||||
properties.getRandomStringLength(), properties.getCustomTemplate());
|
||||
}
|
||||
|
||||
this.fileName = fileName;
|
||||
@@ -522,7 +526,10 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
|
||||
}
|
||||
|
||||
public void randomDuplicateFileName() {
|
||||
this.fileName = FileNameUtils.randomFilenameWithString(originalFileName, 4);
|
||||
this.fileName = FileNameUtils.replaceFilenameWithDuplicateHandling(originalFileName,
|
||||
properties.getRandomFilenameMode(),
|
||||
properties.getRandomStringLength(), properties.getCustomTemplate(),
|
||||
properties.getDuplicateFilenameHandling());
|
||||
this.objectKey = properties.getObjectName(fileName);
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
|
||||
/**
|
||||
* @author johnniang
|
||||
@@ -11,8 +11,8 @@ import run.halo.app.plugin.BasePlugin;
|
||||
@Component
|
||||
public class S3OsPlugin extends BasePlugin {
|
||||
|
||||
public S3OsPlugin(PluginWrapper wrapper) {
|
||||
super(wrapper);
|
||||
public S3OsPlugin(PluginContext pluginContext) {
|
||||
super(pluginContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@@ -29,7 +29,11 @@ class S3OsProperties {
|
||||
*/
|
||||
private String location;
|
||||
|
||||
private String randomFilenameMode = "none";
|
||||
private RandomFilenameMode randomFilenameMode;
|
||||
|
||||
private String customTemplate;
|
||||
|
||||
private DuplicateFilenameHandling duplicateFilenameHandling;
|
||||
|
||||
private Integer randomStringLength = 8;
|
||||
|
||||
@@ -53,6 +57,14 @@ class S3OsProperties {
|
||||
private String urlSuffix;
|
||||
}
|
||||
|
||||
public enum DuplicateFilenameHandling {
|
||||
randomAlphanumeric, randomAlphabetic, exception
|
||||
}
|
||||
|
||||
public enum RandomFilenameMode {
|
||||
none, custom, uuid, timestampMs, dateWithString, datetimeWithString, withString, string, random_number
|
||||
}
|
||||
|
||||
public String getObjectName(String filename) {
|
||||
var objectName = filename;
|
||||
var finalName = FilePathUtils.getFilePathByPlaceholder(getLocation());
|
||||
@@ -62,7 +74,7 @@ class S3OsProperties {
|
||||
return objectName;
|
||||
}
|
||||
|
||||
enum Protocol {
|
||||
public enum Protocol {
|
||||
http, https
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,5 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
@@ -3,7 +3,7 @@ kind: PolicyTemplate
|
||||
metadata:
|
||||
name: s3os
|
||||
spec:
|
||||
displayName: S3 Object Storage
|
||||
displayName: S3 对象存储
|
||||
settingName: s3os-policy-template-setting
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
@@ -14,6 +14,10 @@ spec:
|
||||
forms:
|
||||
- group: default
|
||||
formSchema:
|
||||
- $formkit: verificationForm
|
||||
action: "/apis/s3os.halo.run/v1alpha1/policies/s3/validation"
|
||||
label: 对象存储验证
|
||||
children:
|
||||
- $formkit: text
|
||||
name: bucket
|
||||
label: Bucket 桶名称
|
||||
@@ -62,30 +66,54 @@ spec:
|
||||
name: location
|
||||
label: 上传目录
|
||||
placeholder: 如不填写,则默认上传到根目录
|
||||
help: 支持使用 ${year} ${month} ${day} 占位符
|
||||
help: 支持的占位符请查阅:https://github.com/halo-dev/plugin-s3#上传目录
|
||||
- $formkit: select
|
||||
name: randomFilenameMode
|
||||
label: 上传时重命名文件方式
|
||||
options:
|
||||
- label: 保留原文件名
|
||||
value: none
|
||||
- label: 使用原文件名 + 随机字符串
|
||||
value: withString
|
||||
- label: 使用日期 + 随机字符串
|
||||
value: dateWithString
|
||||
- label: 使用日期时间 + 随机字符串
|
||||
value: datetimeWithString
|
||||
- label: 使用随机字符串
|
||||
value: string
|
||||
- label: 自定义(请在下方输入自定义模板)
|
||||
value: custom
|
||||
- label: 使用UUID
|
||||
value: uuid
|
||||
- label: 使用毫秒时间戳
|
||||
value: timestampMs
|
||||
- label: 使用原文件名 + 随机字母
|
||||
value: withString
|
||||
- label: 使用日期 + 随机字母
|
||||
value: dateWithString
|
||||
- label: 使用日期时间 + 随机字母
|
||||
value: datetimeWithString
|
||||
- label: 使用随机字母
|
||||
value: string
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: randomStringLength
|
||||
label: 随机字符串长度
|
||||
key: randomStringLength
|
||||
label: 随机字母长度
|
||||
min: 4
|
||||
max: 16
|
||||
placeholder: 仅在重命名文件时需要随机字符串时填写(支持4~16位, 默认为8位)
|
||||
if: "$randomFilenameMode == 'dateWithString' || $randomFilenameMode == 'datetimeWithString' || $randomFilenameMode == 'withString' || $randomFilenameMode == 'string'"
|
||||
help: 支持4~16位, 默认为8位
|
||||
- $formkit: text
|
||||
name: customTemplate
|
||||
key: customTemplate
|
||||
label: 自定义文件名模板
|
||||
if: "$randomFilenameMode == 'custom'"
|
||||
value: "${origin-filename}"
|
||||
help: 支持的占位符请查阅:https://github.com/halo-dev/plugin-s3#自定义文件名模板
|
||||
- $formkit: select
|
||||
name: duplicateFilenameHandling
|
||||
label: 重复文件名处理方式
|
||||
options:
|
||||
- label: 加随机字母数字后缀
|
||||
value: randomAlphanumeric
|
||||
- label: 加随机字母后缀
|
||||
value: randomAlphabetic
|
||||
- label: 报错不上传
|
||||
value: exception
|
||||
validation: required
|
||||
- $formkit: select
|
||||
name: protocol
|
||||
label: 绑定域名协议
|
||||
|
@@ -6,7 +6,7 @@ metadata:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/dependencies: |
|
||||
[ "role-template-manage-attachments", "role-template-view-plugins" ]
|
||||
[ "role-template-manage-attachments" ]
|
||||
rbac.authorization.halo.run/module: "S3 Attachments Management"
|
||||
rbac.authorization.halo.run/display-name: "S3 Link"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
@@ -39,3 +39,17 @@ rules:
|
||||
- apiGroups: [ "s3os.halo.run" ]
|
||||
resources: [ "attachments" ]
|
||||
verbs: [ "delete" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: "Role"
|
||||
metadata:
|
||||
name: role-template-s3os-policy-config-validation
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
halo.run/hidden: "true"
|
||||
rbac.authorization.halo.run/aggregate-to-role-template-manage-configmaps: "true"
|
||||
rules:
|
||||
- apiGroups: ["s3os.halo.run"]
|
||||
resources: ["policies/validation"]
|
||||
resourceNames: ["s3"]
|
||||
verbs: [ "create" ]
|
||||
|
@@ -7,8 +7,5 @@ spec:
|
||||
- group: basic
|
||||
label: 使用提示
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
help: 请前往 “附件 - 存储策略” 添加策略
|
||||
label: 此处不用设置,请前往 “附件 - 存储策略” 添加策略
|
||||
name: text
|
||||
placeholder: 此处不用设置,请前往 “附件 - 存储策略” 添加策略
|
||||
- $el: p
|
||||
children: 请前往 “附件 - 存储策略” 添加策略
|
||||
|
@@ -4,14 +4,16 @@ metadata:
|
||||
name: PluginS3ObjectStorage
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.10.0"
|
||||
requires: ">=2.14.0"
|
||||
author:
|
||||
name: Halo OSS Team
|
||||
name: Halo
|
||||
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-dev/plugin-s3
|
||||
homepage: https://www.halo.run/store/apps/app-Qxhpp
|
||||
repo: https://github.com/halo-dev/plugin-s3
|
||||
issues: https://github.com/halo-dev/plugin-s3/issues
|
||||
displayName: "对象存储(Amazon S3 协议)"
|
||||
description: "提供兼容 Amazon S3 协议的对象存储策略,兼容阿里云、腾讯云、七牛云等"
|
||||
license:
|
||||
|
BIN
src/main/resources/validation.jpg
Normal file
BIN
src/main/resources/validation.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
28
src/test/java/run/halo/s3os/FileNameUtilsTest.java
Normal file
28
src/test/java/run/halo/s3os/FileNameUtilsTest.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FileNameUtilsTest {
|
||||
@Test
|
||||
void testReplaceFilenameWithDuplicateHandling() {
|
||||
// Case 1: halo.run -> halo-xyz.run
|
||||
var result = FileNameUtils.replaceFilenameWithDuplicateHandling(
|
||||
"halo.run", S3OsProperties.RandomFilenameMode.none, null,
|
||||
null, S3OsProperties.DuplicateFilenameHandling.randomAlphanumeric);
|
||||
assertTrue(result.matches("halo-[a-z0-9]{4}.run"));
|
||||
|
||||
// Case 2: .run -> xyz.run
|
||||
result = FileNameUtils.replaceFilenameWithDuplicateHandling(
|
||||
".run", S3OsProperties.RandomFilenameMode.none, null,
|
||||
null, S3OsProperties.DuplicateFilenameHandling.randomAlphanumeric);
|
||||
assertTrue(result.matches("[a-z0-9]{4}.run"));
|
||||
|
||||
// Case 3: halo -> halo-xyz
|
||||
result = FileNameUtils.replaceFilenameWithDuplicateHandling(
|
||||
"halo", S3OsProperties.RandomFilenameMode.none, null,
|
||||
null, S3OsProperties.DuplicateFilenameHandling.randomAlphanumeric);
|
||||
assertTrue(result.matches("halo-[a-z0-9]{4}"));
|
||||
}
|
||||
}
|
80
src/test/java/run/halo/s3os/PlaceholderReplacerTest.java
Normal file
80
src/test/java/run/halo/s3os/PlaceholderReplacerTest.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package run.halo.s3os;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PlaceholderReplacerTest {
|
||||
|
||||
@Test
|
||||
void testReplacePlaceholdersTemplateNull() {
|
||||
String result1 = PlaceholderReplacer.replacePlaceholders(null, "test");
|
||||
assertEquals("test", result1);
|
||||
String result2 = PlaceholderReplacer.replacePlaceholders("", "test");
|
||||
assertEquals("test", result2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReplacePlaceholdersAllPlaceholder() {
|
||||
String template = "${origin-filename}-${uuid-with-dash}-${uuid-no-dash}-${timestamp-sec}-" +
|
||||
"${timestamp-ms}-${year}-${month}-${day}-${weekday}-${hour}-${minute}-${second}-" +
|
||||
"${millisecond}-${random-alphabetic:4}-${random-num:5}-${random-alphanumeric:6}";
|
||||
String result = PlaceholderReplacer.replacePlaceholders(template, "test");
|
||||
String regex = "test-" +
|
||||
"[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}-" +
|
||||
"[A-F0-9]{32}-" +
|
||||
"[0-9]{10}-" +
|
||||
"[0-9]{13}-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{1}-[0-9]{2}-[0-9]{2}-[0-9]{2}-" +
|
||||
"[0-9]{3}-[a-z]{4}-[0-9]{5}-[a-z0-9]{6}";
|
||||
assertTrue(result.matches(regex));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReplacePlaceholdersTimestamp() {
|
||||
String template =
|
||||
"${timestamp-sec}-${timestamp-sec}-${timestamp-ms}-${timestamp-ms}-${year}-${year}-" +
|
||||
"${month}-${month}-${day}-${day}-${weekday}-${weekday}-${hour}-${hour}-" +
|
||||
"${minute}-${minute}-${second}-${second}-${millisecond}-${millisecond}";
|
||||
String result = PlaceholderReplacer.replacePlaceholders(template, "test");
|
||||
String[] split = result.split("-");
|
||||
for (int i = 0; i < split.length; i += 2) {
|
||||
assertEquals(split[i], split[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReplacePlaceholdersRandomAlphabeticNoLength() {
|
||||
String template =
|
||||
"${random-alphabetic}_${random-alphabetic:}_${random-alphabetic:-1}_${random-alphabetic:0}";
|
||||
String result = PlaceholderReplacer.replacePlaceholders(template, "test");
|
||||
String regex = "[a-z]{8}_[a-z]{8}_[a-z]{8}_[a-z]{8}";
|
||||
assertTrue(result.matches(regex));
|
||||
|
||||
template = "${random-num}_${random-num:}_${random-num:-1}_${random-num:0}";
|
||||
result = PlaceholderReplacer.replacePlaceholders(template, "test");
|
||||
regex = "[0-9]{8}_[0-9]{8}_[0-9]{8}_[0-9]{8}";
|
||||
assertTrue(result.matches(regex));
|
||||
|
||||
template =
|
||||
"${random-alphanumeric}_${random-alphanumeric:}_${random-alphanumeric:-1}_${random-alphanumeric:0}";
|
||||
result = PlaceholderReplacer.replacePlaceholders(template, "test");
|
||||
regex = "[a-z0-9]{8}_[a-z0-9]{8}_[a-z0-9]{8}_[a-z0-9]{8}";
|
||||
assertTrue(result.matches(regex));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReplacePlaceholdersInvalid() {
|
||||
String template = "file_${not-exist}_test";
|
||||
String result = PlaceholderReplacer.replacePlaceholders(template, "test");
|
||||
assertEquals("file_${not-exist}_test", result);
|
||||
|
||||
template = "file_${random-alphabetic_test";
|
||||
result = PlaceholderReplacer.replacePlaceholders(template, "test");
|
||||
assertEquals("file_${random-alphabetic_test", result);
|
||||
|
||||
template = "file_random-alphabetic}_test";
|
||||
result = PlaceholderReplacer.replacePlaceholders(template, "test");
|
||||
assertEquals("file_random-alphabetic}_test", result);
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user