31 Commits

Author SHA1 Message Date
guqing
40f66ff665 chore: remove deprecated plugin wrapper (#161)
### What this PR does?
移除对已过时的 PluginWrapper 的引用,Halo 2.18.0 版本后将不在支持从 BasePlugin 中获取 PluginWrapper ,也不再支持依赖注入 PluginWrapper,使用 PluginContext 代替

see also https://github.com/halo-dev/halo/pull/6243 for more details

```release-note
None
```
2024-07-02 15:29:25 +00:00
longjuan
cb968de154 fix: apply natural ordering to policy and attachment listings (#160)
```release-note
None
```
传递unsorted以支持Halo 2.17版本
2024-07-02 15:27:25 +00:00
longjuan
bdd62cbe12 faet: Add file prefix search function in s3link (#153)
```release-note
在 S3关联 中支持文件名前缀搜索
```
fixes https://github.com/halo-dev/plugin-s3/issues/142
2024-06-16 12:30:45 +00:00
longjuan
2a26171fc4 Fix: Correct policy selector styling issue (#151)
```release-note
修复 S3 关联中策略选择器太短的问题
```
fixes #141
2024-05-14 02:08:33 +00:00
longjuan
6b62ce7aa4 Hide the permission of the verification interface (#147)
fixes https://github.com/halo-dev/plugin-s3/issues/146
```release-note
在角色创建中隐藏配置校验接口的权限
```
2024-05-06 10:34:54 +00:00
Ryan Wang
c60e31a033 chore: update plugin.yaml file (#138)
完善插件定义文件。

1. 将 homepage 字段指向 Halo 应用市场的地址。
2. 新增 repo 字段指向源码仓库地址。
3. 新增 issues 字段指向 GitHub issues 地址。

```release-note
None
```
2024-05-05 08:39:33 +00:00
Chenhe
7a9b0de0c6 improve usage tips in settings page (#140)
使用占位组件在设置页面展示信息并不恰当,并且此项目作为存储插件的 Example 工程易误导其他开发者。

调整为利用 FormKit `el` 特性实现。

问题背景:https://github.com/halo-dev/halo/issues/5802

**Preview - old**
<img width="373" alt="image" src="https://github.com/halo-dev/plugin-s3/assets/10266066/ac4aaede-6377-459f-a866-9bfe132906af">

**Preview - new**
<img width="373" alt="image" src="https://github.com/halo-dev/plugin-s3/assets/10266066/f884417c-045a-4396-84be-633d18ae36c9">

```release-note
优化调整提示的提示
```
2024-04-28 11:54:48 +00:00
longjuan
5e7e6620fd chore: bump version to 1.9.0 (#135)
```release-note
None
```
2024-04-22 06:08:08 +00:00
longjuan
47b6a37d0a feat: add verification function to the configuration of s3 object storage policy (#134)
```release-note
在 S3 存储策略配置中增加了验证配置的功能。
```
fixes https://github.com/halo-dev/plugin-s3/issues/132
2024-04-22 04:26:10 +00:00
longjuan
68b1a88b14 Fix original file name being incorrectly replaced (#129) 2024-04-14 20:25:05 +08:00
longjuan
f4ec56b7bc perf: optimize s3link interfaces using index mechanisms (#127)
* perf: optimize s3link list queries using index mechanisms
2024-04-03 12:10:54 +08:00
John Niang
c5d4e719a7 Simplify GitHub workflow by using reusable workflow (#130) 2024-03-26 22:11:52 +08:00
Ryan Wang
034b3f3ded chore: rename displayName to S3 对象存储 (#128)
在 Halo 完成动态数据的 i18n 之前,使用中文描述存储策略名称。

/kind improvement

<img width="821" alt="image" src="https://github.com/halo-dev/plugin-s3/assets/21301288/bc5fd32c-f7fc-43de-8bc4-cdbdd7e940d6">

```release-note
修改存储策略名称为中文显示。
```
2024-03-24 11:54:08 +00:00
longjuan
2503c6eba1 fix: Correct the formatting of Milliseconds to be a 3-digit number (#126)
```release-note
修复重命名模板中 millisecond 没有被格式化3位数的问题
```
2024-03-18 01:44:08 +00:00
John Niang
84aa7d32ba Correct release types to published instead of created 2024-03-05 22:23:26 +08:00
longjuan
73bd9e9948 chore: correcting incorrect policy template field help text (#124)
```release-note
None
```
2024-03-01 12:41:02 +00:00
longjuan
71c9784b64 chore: bump version to 1.8.0 (#123)
```release-note
None
```
2024-03-01 12:39:02 +00:00
longjuan
a16bbde9dd feat: add support for custom template in automatic renaming during upload (#115)
```release-note
上传自动重命名支持自定义模板,支持更多占位符
```

fixes #110
fixes #98
2024-02-29 13:53:38 +00:00
longjuan
9efa4b97e5 chore: remove plugin view permissions from S3 Link permission dependency (#121)
```release-note
None
```
入口已从插件设置页移除,移除依赖插件查看权限
2024-02-25 05:22:08 +00:00
longjuan
5c95a04a07 chore: updated s3 link entrance description (#120)
```release-note
None
```
2024-02-25 05:20:08 +00:00
longjuan
a63f3f4dc3 fix: missing page size selector style (#119)
```release-note
修复关联S3文件页面中 page size 下拉框样式缺失的问题
```
Before:
![image](https://github.com/halo-dev/plugin-s3/assets/28662535/0bc89958-ce28-4be8-a6ce-6a4688c61c98)

After:
![image](https://github.com/halo-dev/plugin-s3/assets/28662535/6099c2d0-e346-45eb-baa8-cd9e20c04b04)
2024-02-20 07:50:09 +00:00
Ryan Wang
11cbfdb9bb chore: simplify bundler config (#117)
使用 `@halo-dev/ui-plugin-bundler-kit` 简化构建配置。

/kind improvement

```release-note
None
```
2024-02-05 02:56:31 +00:00
Ryan Wang
2b0dd98575 refactor: place the entry for the S3 link page in the side menu (#116)
将 S3 关联页面放置在左侧菜单。

/kind improvement

Note: 此特性需要运行在 Halo 2.12 才生效,所以此 PR 同时修改了 requires。

<img width="1920" alt="image" src="https://github.com/halo-dev/plugin-s3/assets/21301288/a78d7837-81c2-4c50-ad19-2c40e74cbc41">

<img width="1920" alt="image" src="https://github.com/halo-dev/plugin-s3/assets/21301288/41edeed8-9ce0-40d1-82a9-1fb376a08c51">


```release-note
将 S3 关联页面放置在左侧菜单。
```
2024-02-03 15:50:55 +00:00
longjuan
8ff4acba6e perf: improve more friendly exception messages (#113)
```release-note
友好地提示异常信息
```
fixes https://github.com/halo-dev/plugin-s3/issues/105

验证方法:
1. ak/sk乱输,发生接收到403状态码(接收错误状态码)
2. endpoint网址改成不存在的,如.com改成.comaaa(未知主机)
3. endpoint端口改成没监听的(超时)
2024-01-16 14:14:12 +00:00
longjuan
82f409e349 chore: bump version to 1.7.0 (#107)
```release-note
None
```
已测试2.10.0和2.11.0无问题
2023-12-02 04:00:07 +00:00
longjuan
71d303404a chore: bold in README followed by a space (#106)
```release-note
None
```
![image](https://github.com/halo-dev/plugin-s3/assets/28662535/c81be5ba-d575-4cb1-ba68-611bc1d1bbea)
![image](https://github.com/halo-dev/plugin-s3/assets/28662535/399fe27b-5731-4345-89a1-fe1556912c61)

加粗后面加上空格
2023-12-01 07:56:10 +00:00
longjuan
b2cd58a7f7 chore: improve the usage and configuration guidelines in Readme (#104)
```release-note
None
```
完善使用方法和配置指南的重命名方式
2023-12-01 07:42:09 +00:00
longjuan
3fda6e0cde fix: log cannot be output and log information error (#101)
```release-note
修复无日志输出的问题
```
fixes #100 
测试上传,关联,删除,解除关联 4种操作的日志
```
---上传---
2023-11-27T22:39:22.139+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : operation: createMultipartUpload, result: CreateMultipartUploadResponse(Bucket=test-1305034426, Key=test/image-pjcl.png, UploadId=17010959629c897f8b8c1a592b0efe300de867a1ea3e891cf04b4f95c6986470a0f30195be)
2023-11-27T22:39:22.391+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : operation: uploadPart, result: UploadPartResponse(ETag="aa84eea735192b49552fa5bf3df317fa")
2023-11-27T22:39:22.547+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : operation: completeUpload, result: CompleteMultipartUploadResponse(Location=http://test-1305034426.cos.ap-guangzhou.myqcloud.com/test/image-pjcl.png, Bucket=test-1305034426, Key=test/image-pjcl.png, ETag="afdd5ce39cf49676238fcfc879664b55-1")
2023-11-27T22:39:22.616+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : operation: getMetadata, result: HeadObjectResponse(AcceptRanges=bytes, LastModified=2023-11-27T14:39:22Z, ContentLength=1420, ETag="afdd5ce39cf49676238fcfc879664b55-1", ContentType=image/png, Metadata={})
2023-11-27T22:39:22.616+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : Upload object test/image-pjcl.png to bucket test-1305034426 successfully
2023-11-27T22:39:22.626+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : Build attachment test/image-pjcl.png successfully
2023-11-27T22:39:29.915+08:00  WARN 7 --- [or-http-epoll-1] ocalVariableTableParameterNameDiscoverer : Using deprecated '-debug' fallback for parameter name resolution. Compile the affected code with '-parameters' instead or avoid its introspection: run.halo.s3os.S3LinkController
---关联---
2023-11-27T22:39:34.657+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : Build attachment test/34.txt successfully
---解除关联---
2023-11-27T22:39:39.937+08:00  INFO 7 --- [tReconciler-t-1] run.halo.s3os.S3OsAttachmentHandler      : Skip deleting object test/34.txt from S3.
---删除---
2023-11-27T22:39:44.290+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : operation: delete object, result: DeleteObjectResponse()
2023-11-27T22:39:44.291+08:00  INFO 7 --- [oundedElastic-5] run.halo.s3os.S3OsAttachmentHandler      : Delete object test/image-pjcl.png from bucket test-1305034426 successfully
```
2023-12-01 07:40:09 +00:00
longjuan
ac9c9b88a6 fix: list s3 file error in Halo 2.11 (#103)
```release-note
修复 Halo 2.11 中 关联S3文件 功能报错的问题
```
fixes #102
2023-11-28 02:46:10 +00:00
longjuan
77c8b21248 feat: only delete halo attachment records without deleting files in object storage (#97)
/kind feature
```release-note
提供解除 S3 关联功能(仅删除 Halo 附件记录,而不再对象存储中实际删除)
```
在附件库中选择使用本插件上传或关联的文件的更多插件,会出现 解除 S3 关联 按钮。
![image](https://github.com/halo-dev/plugin-s3/assets/28662535/6bfd109e-3be5-4fdc-afb0-bbf841dfc8e8)
对于其他附件,不会出现该按钮
![image](https://github.com/halo-dev/plugin-s3/assets/28662535/bc90166d-68c5-4599-b00a-5d2ddd868314)
本功能已支持权限管理。
2023-11-27 13:50:09 +00:00
longjuan
6f59d0ee32 chore: update .gitignore and remove untracked directories (#95)
移除 `src/main/resources/console/main.js` 并加入.gitignore
```release-note
None
```
2023-10-28 06:05:30 +00:00
42 changed files with 1512 additions and 586 deletions

17
.github/workflows/cd.yaml vendored Normal file
View 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
View 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

View File

@@ -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 }}

2
.gitignore vendored
View File

@@ -70,4 +70,6 @@ application-local.yml
application-local.yaml
application-local.properties
/admin-frontend/node_modules/
/workplace/
/src/main/resources/console/

105
README.md
View File

@@ -8,12 +8,29 @@
- 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 存储策略。
3. 配置存储策略:
* 在 Halo 后台管理系统中,点击左侧“附件”导航至附件管理页面,点击右上角的存储策略。
* 在存储策略管理界面中,您可以新建和编辑 S3 Object Storage 存储策略。
4. 上传到存储策略:
* 在附件页面中点击上传按钮,选择好存储策略后上传文件即可上传到对应的对象存储中。
* 在 Halo 2.11 以上版本中可在 Halo 设置界面中设定文章附件、头像等的默认存储策略。
5. 使用“关联 S3 文件”功能:
* 在左侧侧边导航栏中,点击工具,再点击 S3 关联。
* 在此界面中,您可以浏览并选择已在对象存储中但不是通过 Halo 上传的文件,关联后会在 Halo 生成相应的附件记录。这些文件现在可以方便地在 Halo 中管理和使用。
6. 使用“解除 S3 关联”功能:
* 在附件页面中,找到由本插件管理的附件记录,点击更多操作(右侧的三个点)按钮,然后点击“解除 S3 关联”按钮。
* 此操作将仅删除 Halo 中的附件记录,而不会实际删除对象存储中的文件。如需恢复请使用“关联 S3 文件”功能。
## 配置指南
### Bucket 桶名称
一般与服务商控制台中的空间名称一致。
> 注意部分服务商 s3 空间名 ≠ 空间名称若出现“Access Denied”报错可检查 Bucket 是否正确。
>
> 可通过 S3Browser 查看桶列表,七牛云也可在“开发者平台-对象存储-空间概览-s3域名”中查看 s3 空间名。
### Endpoint 访问风格
请根据下方表格中的兼容访问风格选择,若您的服务商不在表格中,请自行查看服务商的 s3 兼容性文档或自行尝试。
@@ -35,14 +52,6 @@
与服务商自己 API 的 Access Key 和 Access Secret 相同,详情查看对应服务商的文档。
### Bucket 桶名称
一般与服务商控制台中的空间名称一致。
> 注意部分服务商 s3 空间名 ≠ 空间名称若出现“Access Denied”报错可检查 Bucket 是否正确。
>
> 可通过 S3Browser 查看桶列表,七牛云也可在“开发者平台-对象存储-空间概览-s3域名”中查看 s3 空间名。
### Region
一般留空即可。
@@ -51,6 +60,80 @@
>
> Cloudflare 需要填写均为小写字母的 `auto`。
### 上传目录
上传到对象存储的目录,前后`/`可省略,例如`/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 错误。
## 部分对象存储服务商兼容性
|服务商|文档|兼容访问风格|兼容性|

View File

@@ -16,11 +16,14 @@ 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')
implementation 'software.amazon.awssdk:s3'
implementation ('software.amazon.awssdk:s3') {
exclude group: 'org.slf4j'
exclude group: 'commons-logging'
}
testImplementation 'run.halo.app:api'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
@@ -33,7 +36,7 @@ configurations.runtimeClasspath {
halo {
version = '2.10.0'
version = '2.14.0'
}
haloPlugin {
@@ -56,6 +59,10 @@ task buildFrontend(type: PnpmTask) {
args = ['build']
}
tasks.named('buildFrontend') {
dependsOn 'pnpmInstall'
}
build {
// build frontend before build
tasks.getByName('compileJava').dependsOn('buildFrontend')

View File

@@ -11,14 +11,17 @@
"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",
"@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",
"path-browserify": "^1.0.1",
"vue": "^3.2.41"
"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",

334
console/pnpm-lock.yaml generated
View File

@@ -1,12 +1,22 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@halo-dev/api-client':
specifier: ^2.11.0
version: 2.11.0
'@halo-dev/components':
specifier: ^1.5.0
version: 1.5.0(vue@3.2.41)
specifier: ^1.10.0
version: 1.10.0(vue-router@4.2.5)(vue@3.3.7)
'@halo-dev/console-shared':
specifier: ^2.8.0
version: 2.8.0(vue@3.2.41)
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)
axios:
specifier: ^1.4.0
version: 1.4.0
@@ -17,10 +27,13 @@ dependencies:
specifier: ^1.0.1
version: 1.0.1
vue:
specifier: ^3.2.41
version: 3.2.41
specifier: ^3.3.4
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
@@ -38,10 +51,10 @@ devDependencies:
version: 16.18.0
'@vitejs/plugin-vue':
specifier: ^3.1.2
version: 3.1.2(vite@3.1.8)(vue@3.2.41)
version: 3.1.2(vite@3.1.8)(vue@3.3.7)
'@vitejs/plugin-vue-jsx':
specifier: ^2.0.1
version: 2.0.1(vite@3.1.8)(vue@3.2.41)
version: 2.0.1(vite@3.1.8)(vue@3.3.7)
'@vue/eslint-config-prettier':
specifier: ^7.0.0
version: 7.0.0(eslint@8.26.0)(prettier@2.7.1)
@@ -50,7 +63,7 @@ devDependencies:
version: 11.0.2(eslint-plugin-vue@9.6.0)(eslint@8.26.0)(typescript@4.7.4)
'@vue/test-utils':
specifier: ^2.2.0
version: 2.2.0(vue@3.2.41)
version: 2.2.0(vue@3.3.7)
'@vue/tsconfig':
specifier: ^0.1.3
version: 0.1.3(@types/node@16.18.0)
@@ -322,16 +335,15 @@ packages:
/@babel/helper-string-parser@7.19.4:
resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/helper-validator-identifier@7.16.7:
resolution: {integrity: sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/helper-validator-identifier@7.19.1:
resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/helper-validator-option@7.18.6:
resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==}
@@ -364,6 +376,7 @@ packages:
hasBin: true
dependencies:
'@babel/types': 7.18.4
dev: true
/@babel/parser@7.19.6:
resolution: {integrity: sha512-h1IUp81s2JYJ3mRkdxJgs4UvmSsRvDrx5ICSJbPvtWYv5i1nTBGcBpnog+89rAFMwvvru6E5NUHdBe01UeSzYA==}
@@ -373,6 +386,13 @@ packages:
'@babel/types': 7.19.4
dev: true
/@babel/parser@7.23.0:
resolution: {integrity: sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==}
engines: {node: '>=6.0.0'}
hasBin: true
dependencies:
'@babel/types': 7.19.4
/@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.19.6):
resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
peerDependencies:
@@ -476,6 +496,7 @@ packages:
dependencies:
'@babel/helper-validator-identifier': 7.16.7
to-fast-properties: 2.0.0
dev: true
/@babel/types@7.19.4:
resolution: {integrity: sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==}
@@ -484,7 +505,6 @@ packages:
'@babel/helper-string-parser': 7.19.4
'@babel/helper-validator-identifier': 7.19.1
to-fast-properties: 2.0.0
dev: true
/@esbuild/android-arm@0.15.12:
resolution: {integrity: sha512-IC7TqIqiyE0MmvAhWkl/8AEzpOtbhRNDo7aph47We1NbE5w2bt/Q+giAhe0YYeVpYnIhGMcuZY92qDK6dQauvA==}
@@ -521,40 +541,59 @@ packages:
- supports-color
dev: true
/@floating-ui/core@0.3.1:
resolution: {integrity: sha512-ensKY7Ub59u16qsVIFEo2hwTCqZ/r9oZZFh51ivcLGHfUwTn8l1Xzng8RJUe91H/UP8PeqeBronAGx0qmzwk2g==}
dev: false
/@floating-ui/dom@0.1.10:
resolution: {integrity: sha512-4kAVoogvQm2N0XE0G6APQJuCNuErjOfPW8Ux7DFxh8+AfugWflwVJ5LDlHOwrwut7z/30NUvdtHzQ3zSip4EzQ==}
/@floating-ui/core@1.5.0:
resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==}
dependencies:
'@floating-ui/core': 0.3.1
'@floating-ui/utils': 0.1.6
dev: false
/@halo-dev/api-client@2.8.0:
resolution: {integrity: sha512-mVfYYO437TOshRppCnfYBbc4pvbpNWPmlsv8UAWr3F3Zs3LafcHcF4hb7rq03qfEahsnoLtX8jXVWjmFtf4xqw==}
/@floating-ui/dom@1.1.1:
resolution: {integrity: sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==}
dependencies:
'@floating-ui/core': 1.5.0
dev: false
/@halo-dev/components@1.5.0(vue@3.2.41):
resolution: {integrity: sha512-zVRY2AzeE83fR5omZO8q6R/kAAxZ7iSgVNcLxTjOXSPl0SP3HD4LWXoloO1rWoa/O/JUlFY2WqBAEWnu8miCGA==}
/@floating-ui/utils@0.1.6:
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
dev: false
/@halo-dev/api-client@2.11.0:
resolution: {integrity: sha512-i3PFETsPdHYnTgk3jORu00t43/rCesmqpdZg38/Hq2AdgxhPkE7rghYGdoZRLdanvVC0HM1Axn18Zd7kdizxVA==}
dev: false
/@halo-dev/components@1.10.0(vue-router@4.2.5)(vue@3.3.7):
resolution: {integrity: sha512-Qg7JEkuVyTAqTjuLJHQifhMGl180yZTOX2cSueAssFCuyGZtVCcN/o5FmDtrcw8UXoV8vRwxvpixgjxFwlf/4Q==}
peerDependencies:
vue: ^3.2.37
vue-router: ^4.0.16
vue: ^3.3.4
vue-router: ^4.2.4
dependencies:
floating-vue: 2.0.0-beta.20(vue@3.2.41)
vue: 3.2.41
floating-vue: 2.0.0-beta.24(vue@3.3.7)
vue: 3.3.7(typescript@4.7.4)
vue-router: 4.2.5(vue@3.3.7)
transitivePeerDependencies:
- '@nuxt/kit'
dev: false
/@halo-dev/console-shared@2.8.0(vue@3.2.41):
resolution: {integrity: sha512-Zhi5FbVrdzSSaaT6i2xh8JOhmi9keetjg8zrYd+tEp4Iy+mPke4ZA4SIYaO1Bkwr9MD5BZ+sNswgYCPd36wYaw==}
/@halo-dev/console-shared@2.11.0(vue-router@4.2.5)(vue@3.3.7):
resolution: {integrity: sha512-XDyoHsueVgQOvMTDm4Fx3qKzCjXd7bI9eC0DFuw3w85Y3LQeHgrJfbXRlMRCTTZhe3kgpBOra4JjByyFFWa/Cw==}
peerDependencies:
vue: ^3.2.37
vue-router: ^4.0.16
vue: ^3.3.4
vue-router: ^4.2.4
dependencies:
'@halo-dev/api-client': 2.8.0
vue: 3.2.41
'@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'}
@@ -630,6 +669,9 @@ packages:
resolution: {integrity: sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==}
dev: true
/@jridgewell/sourcemap-codec@1.4.15:
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
/@jridgewell/trace-mapping@0.3.13:
resolution: {integrity: sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==}
dependencies:
@@ -662,6 +704,33 @@ packages:
resolution: {integrity: sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==}
dev: true
/@tanstack/match-sorter-utils@8.8.4:
resolution: {integrity: sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==}
engines: {node: '>=12'}
dependencies:
remove-accents: 0.4.2
dev: false
/@tanstack/query-core@4.29.1:
resolution: {integrity: sha512-vkPewLEG8ua0efo3SsVT0BcBtkq5RZX8oPhDAyKL+k/rdOYSQTEocfGEXSaBwIwsXeOGBUpfKqI+UmHvNqdWXg==}
dev: false
/@tanstack/vue-query@4.29.1(vue@3.3.7):
resolution: {integrity: sha512-2/F12lOHQw3YQbHVKGZUTEuwxrws6UR3VEXNX/Obw2NLupmCYR7+dcBwwHQ75zUQngxLFQwHCJKX7Ne1Wyk/HA==}
peerDependencies:
'@vue/composition-api': ^1.1.2
vue: ^2.5.0 || ^3.0.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
dependencies:
'@tanstack/match-sorter-utils': 8.8.4
'@tanstack/query-core': 4.29.1
'@vue/devtools-api': 6.5.1
vue: 3.3.7(typescript@4.7.4)
vue-demi: 0.13.11(vue@3.3.7)
dev: false
/@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -827,7 +896,7 @@ packages:
eslint-visitor-keys: 3.3.0
dev: true
/@vitejs/plugin-vue-jsx@2.0.1(vite@3.1.8)(vue@3.2.41):
/@vitejs/plugin-vue-jsx@2.0.1(vite@3.1.8)(vue@3.3.7):
resolution: {integrity: sha512-lmiR1k9+lrF7LMczO0pxtQ8mOn6XeppJDHxnpxkJQpT5SiKz4SKhKdeNstXaTNuR8qZhUo5X0pJlcocn72Y4Jg==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
@@ -839,12 +908,12 @@ packages:
'@babel/plugin-transform-typescript': 7.19.3(@babel/core@7.19.6)
'@vue/babel-plugin-jsx': 1.1.1(@babel/core@7.19.6)
vite: 3.1.8(sass@1.58.0)
vue: 3.2.41
vue: 3.3.7(typescript@4.7.4)
transitivePeerDependencies:
- supports-color
dev: true
/@vitejs/plugin-vue@3.1.2(vite@3.1.8)(vue@3.2.41):
/@vitejs/plugin-vue@3.1.2(vite@3.1.8)(vue@3.3.7):
resolution: {integrity: sha512-3zxKNlvA3oNaKDYX0NBclgxTQ1xaFdL7PzwF6zj9tGFziKwmBa3Q/6XcJQxudlT81WxDjEhHmevvIC4Orc1LhQ==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
@@ -852,7 +921,7 @@ packages:
vue: ^3.2.25
dependencies:
vite: 3.1.8(sass@1.58.0)
vue: 3.2.41
vue: 3.3.7(typescript@4.7.4)
dev: true
/@volar/language-core@1.0.9:
@@ -923,12 +992,28 @@ packages:
'@vue/shared': 3.2.41
estree-walker: 2.0.2
source-map: 0.6.1
dev: true
/@vue/compiler-core@3.3.7:
resolution: {integrity: sha512-pACdY6YnTNVLXsB86YD8OF9ihwpolzhhtdLVHhBL6do/ykr6kKXNYABRtNMGrsQXpEXXyAdwvWWkuTbs4MFtPQ==}
dependencies:
'@babel/parser': 7.23.0
'@vue/shared': 3.3.7
estree-walker: 2.0.2
source-map-js: 1.0.2
/@vue/compiler-dom@3.2.41:
resolution: {integrity: sha512-xe5TbbIsonjENxJsYRbDJvthzqxLNk+tb3d/c47zgREDa/PCp6/Y4gC/skM4H6PIuX5DAxm7fFJdbjjUH2QTMw==}
dependencies:
'@vue/compiler-core': 3.2.41
'@vue/shared': 3.2.41
dev: true
/@vue/compiler-dom@3.3.7:
resolution: {integrity: sha512-0LwkyJjnUPssXv/d1vNJ0PKfBlDoQs7n81CbO6Q0zdL7H1EzqYRrTVXDqdBVqro0aJjo/FOa1qBAPVI4PGSHBw==}
dependencies:
'@vue/compiler-core': 3.3.7
'@vue/shared': 3.3.7
/@vue/compiler-sfc@3.2.41:
resolution: {integrity: sha512-+1P2m5kxOeaxVmJNXnBskAn3BenbTmbxBxWOtBq3mQTCokIreuMULFantBUclP0+KnzNCMOvcnKinqQZmiOF8w==}
@@ -943,12 +1028,38 @@ packages:
magic-string: 0.25.9
postcss: 8.4.14
source-map: 0.6.1
dev: true
/@vue/compiler-sfc@3.3.7:
resolution: {integrity: sha512-7pfldWy/J75U/ZyYIXRVqvLRw3vmfxDo2YLMwVtWVNew8Sm8d6wodM+OYFq4ll/UxfqVr0XKiVwti32PCrruAw==}
dependencies:
'@babel/parser': 7.23.0
'@vue/compiler-core': 3.3.7
'@vue/compiler-dom': 3.3.7
'@vue/compiler-ssr': 3.3.7
'@vue/reactivity-transform': 3.3.7
'@vue/shared': 3.3.7
estree-walker: 2.0.2
magic-string: 0.30.5
postcss: 8.4.31
source-map-js: 1.0.2
/@vue/compiler-ssr@3.2.41:
resolution: {integrity: sha512-Y5wPiNIiaMz/sps8+DmhaKfDm1xgj6GrH99z4gq2LQenfVQcYXmHIOBcs5qPwl7jaW3SUQWjkAPKMfQemEQZwQ==}
dependencies:
'@vue/compiler-dom': 3.2.41
'@vue/shared': 3.2.41
dev: true
/@vue/compiler-ssr@3.3.7:
resolution: {integrity: sha512-TxOfNVVeH3zgBc82kcUv+emNHo+vKnlRrkv8YvQU5+Y5LJGJwSNzcmLUoxD/dNzv0bhQ/F0s+InlgV0NrApJZg==}
dependencies:
'@vue/compiler-dom': 3.3.7
'@vue/shared': 3.3.7
/@vue/devtools-api@6.5.1:
resolution: {integrity: sha512-+KpckaAQyfbvshdDW5xQylLni1asvNSGme1JFs8I1+/H5pHEhqUKMEQD/qn3Nx5+/nycBq11qAEi8lk+LXI2dA==}
dev: false
/@vue/eslint-config-prettier@7.0.0(eslint@8.26.0)(prettier@2.7.1):
resolution: {integrity: sha512-/CTc6ML3Wta1tCe1gUeO0EYnVXfo3nJXsIhZ8WJr3sov+cGASr6yuiibJTL6lmIBm7GobopToOuB3B6AWyV0Iw==}
@@ -991,43 +1102,63 @@ packages:
'@vue/shared': 3.2.41
estree-walker: 2.0.2
magic-string: 0.25.9
dev: true
/@vue/reactivity-transform@3.3.7:
resolution: {integrity: sha512-APhRmLVbgE1VPGtoLQoWBJEaQk4V8JUsqrQihImVqKT+8U6Qi3t5ATcg4Y9wGAPb3kIhetpufyZ1RhwbZCIdDA==}
dependencies:
'@babel/parser': 7.23.0
'@vue/compiler-core': 3.3.7
'@vue/shared': 3.3.7
estree-walker: 2.0.2
magic-string: 0.30.5
/@vue/reactivity@3.2.41:
resolution: {integrity: sha512-9JvCnlj8uc5xRiQGZ28MKGjuCoPhhTwcoAdv3o31+cfGgonwdPNuvqAXLhlzu4zwqavFEG5tvaoINQEfxz+l6g==}
dependencies:
'@vue/shared': 3.2.41
dev: true
/@vue/runtime-core@3.2.41:
resolution: {integrity: sha512-0LBBRwqnI0p4FgIkO9q2aJBBTKDSjzhnxrxHYengkAF6dMOjeAIZFDADAlcf2h3GDALWnblbeprYYpItiulSVQ==}
/@vue/reactivity@3.3.7:
resolution: {integrity: sha512-cZNVjWiw00708WqT0zRpyAgduG79dScKEPYJXq2xj/aMtk3SKvL3FBt2QKUlh6EHBJ1m8RhBY+ikBUzwc7/khg==}
dependencies:
'@vue/reactivity': 3.2.41
'@vue/shared': 3.2.41
'@vue/shared': 3.3.7
/@vue/runtime-dom@3.2.41:
resolution: {integrity: sha512-U7zYuR1NVIP8BL6jmOqmapRAHovEFp7CSw4pR2FacqewXNGqZaRfHoNLQsqQvVQ8yuZNZtxSZy0FFyC70YXPpA==}
/@vue/runtime-core@3.3.7:
resolution: {integrity: sha512-LHq9du3ubLZFdK/BP0Ysy3zhHqRfBn80Uc+T5Hz3maFJBGhci1MafccnL3rpd5/3wVfRHAe6c+PnlO2PAavPTQ==}
dependencies:
'@vue/runtime-core': 3.2.41
'@vue/shared': 3.2.41
csstype: 2.6.20
'@vue/reactivity': 3.3.7
'@vue/shared': 3.3.7
/@vue/server-renderer@3.2.41(vue@3.2.41):
resolution: {integrity: sha512-7YHLkfJdTlsZTV0ae5sPwl9Gn/EGr2hrlbcS/8naXm2CDpnKUwC68i1wGlrYAfIgYWL7vUZwk2GkYLQH5CvFig==}
/@vue/runtime-dom@3.3.7:
resolution: {integrity: sha512-PFQU1oeJxikdDmrfoNQay5nD4tcPNYixUBruZzVX/l0eyZvFKElZUjW4KctCcs52nnpMGO6UDK+jF5oV4GT5Lw==}
dependencies:
'@vue/runtime-core': 3.3.7
'@vue/shared': 3.3.7
csstype: 3.1.2
/@vue/server-renderer@3.3.7(vue@3.3.7):
resolution: {integrity: sha512-UlpKDInd1hIZiNuVVVvLgxpfnSouxKQOSE2bOfQpBuGwxRV/JqqTCyyjXUWiwtVMyeRaZhOYYqntxElk8FhBhw==}
peerDependencies:
vue: 3.2.41
vue: 3.3.7
dependencies:
'@vue/compiler-ssr': 3.2.41
'@vue/shared': 3.2.41
vue: 3.2.41
'@vue/compiler-ssr': 3.3.7
'@vue/shared': 3.3.7
vue: 3.3.7(typescript@4.7.4)
/@vue/shared@3.2.41:
resolution: {integrity: sha512-W9mfWLHmJhkfAmV+7gDjcHeAWALQtgGT3JErxULl0oz6R6+3ug91I7IErs93eCFhPCZPHBs4QJS7YWEV7A3sxw==}
dev: true
/@vue/test-utils@2.2.0(vue@3.2.41):
/@vue/shared@3.3.7:
resolution: {integrity: sha512-N/tbkINRUDExgcPTBvxNkvHGu504k8lzlNQRITVnm6YjOjwa4r0nnbd4Jb01sNpur5hAllyRJzSK5PvB9PPwRg==}
/@vue/test-utils@2.2.0(vue@3.3.7):
resolution: {integrity: sha512-EKp5/N7ieNZdoLTkD16j/irUjIEDN63QUIc41vLUMqGvSsTQN0QxbFiQqh5v49RPfS5vZH+DhjNUEkijCMOCSg==}
peerDependencies:
vue: ^3.0.1
dependencies:
vue: 3.2.41
vue: 3.3.7(typescript@4.7.4)
dev: true
/@vue/tsconfig@0.1.3(@types/node@16.18.0):
@@ -1354,8 +1485,8 @@ packages:
cssom: 0.3.8
dev: true
/csstype@2.6.20:
resolution: {integrity: sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==}
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
/data-urls@3.0.2:
resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==}
@@ -1978,14 +2109,18 @@ packages:
resolution: {integrity: sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==}
dev: true
/floating-vue@2.0.0-beta.20(vue@3.2.41):
resolution: {integrity: sha512-N68otcpp6WwcYC7zP8GeJqNZVdfvS7tEY88lwmuAHeqRgnfWx1Un8enzLxROyVnBDZ3TwUoUdj5IFg+bUT7JeA==}
/floating-vue@2.0.0-beta.24(vue@3.3.7):
resolution: {integrity: sha512-URSzP6YXaF4u1oZ9XGL8Sn8puuM7ivp5jkOUrpy5Q1mfo9BfGppJOn+ierTmsSUfJEeHBae8KT7r5DeI3vQIEw==}
peerDependencies:
'@nuxt/kit': ^3.2.0
vue: ^3.2.0
peerDependenciesMeta:
'@nuxt/kit':
optional: true
dependencies:
'@floating-ui/dom': 0.1.10
vue: 3.2.41
vue-resize: 2.0.0-alpha.1(vue@3.2.41)
'@floating-ui/dom': 1.1.1
vue: 3.3.7(typescript@4.7.4)
vue-resize: 2.0.0-alpha.1(vue@3.3.7)
dev: false
/follow-redirects@1.15.2:
@@ -2534,6 +2669,13 @@ packages:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
dependencies:
sourcemap-codec: 1.4.8
dev: true
/magic-string@0.30.5:
resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==}
engines: {node: '>=12'}
dependencies:
'@jridgewell/sourcemap-codec': 1.4.15
/memorystream@0.3.1:
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
@@ -2597,6 +2739,12 @@ packages:
resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: true
/nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
/natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -2834,6 +2982,7 @@ packages:
nanoid: 3.3.4
picocolors: 1.0.0
source-map-js: 1.0.2
dev: true
/postcss@8.4.18:
resolution: {integrity: sha512-Wi8mWhncLJm11GATDaQKobXSNEYGUHeQLiQqDFG1qQ5UTDPTEvKw0Xt5NsTpktGTwLps3ByrWsBrG0rB8YQ9oA==}
@@ -2844,6 +2993,14 @@ packages:
source-map-js: 1.0.2
dev: true
/postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
nanoid: 3.3.6
picocolors: 1.0.0
source-map-js: 1.0.2
/prelude-ls@1.1.2:
resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==}
engines: {node: '>= 0.8.0'}
@@ -2914,6 +3071,10 @@ packages:
engines: {node: '>=8'}
dev: true
/remove-accents@0.4.2:
resolution: {integrity: sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==}
dev: false
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -3058,9 +3219,11 @@ packages:
/source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
dev: true
/sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
dev: true
/spdx-correct@3.1.1:
resolution: {integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==}
@@ -3251,7 +3414,6 @@ packages:
resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: true
/unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
@@ -3402,6 +3564,21 @@ packages:
- terser
dev: true
/vue-demi@0.13.11(vue@3.3.7):
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
dependencies:
vue: 3.3.7(typescript@4.7.4)
dev: false
/vue-eslint-parser@9.1.0(eslint@8.26.0):
resolution: {integrity: sha512-NGn/iQy8/Wb7RrRa4aRkokyCZfOUWk19OP5HP6JEozQFX5AoS/t+Z0ZN7FY4LlmWc4FNI922V7cvX28zctN8dQ==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -3420,12 +3597,21 @@ packages:
- supports-color
dev: true
/vue-resize@2.0.0-alpha.1(vue@3.2.41):
/vue-resize@2.0.0-alpha.1(vue@3.3.7):
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
peerDependencies:
vue: ^3.0.0
dependencies:
vue: 3.2.41
vue: 3.3.7(typescript@4.7.4)
dev: false
/vue-router@4.2.5(vue@3.3.7):
resolution: {integrity: sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==}
peerDependencies:
vue: ^3.2.0
dependencies:
'@vue/devtools-api': 6.5.1
vue: 3.3.7(typescript@4.7.4)
dev: false
/vue-template-compiler@2.7.13:
@@ -3446,14 +3632,20 @@ packages:
typescript: 4.7.4
dev: true
/vue@3.2.41:
resolution: {integrity: sha512-uuuvnrDXEeZ9VUPljgHkqB5IaVO8SxhPpqF2eWOukVrBnRBx2THPSGQBnVRt0GrIG1gvCmFXMGbd7FqcT1ixNQ==}
/vue@3.3.7(typescript@4.7.4):
resolution: {integrity: sha512-YEMDia1ZTv1TeBbnu6VybatmSteGOS3A3YgfINOfraCbf85wdKHzscD6HSS/vB4GAtI7sa1XPX7HcQaJ1l24zA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@vue/compiler-dom': 3.2.41
'@vue/compiler-sfc': 3.2.41
'@vue/runtime-dom': 3.2.41
'@vue/server-renderer': 3.2.41(vue@3.2.41)
'@vue/shared': 3.2.41
'@vue/compiler-dom': 3.3.7
'@vue/compiler-sfc': 3.3.7
'@vue/runtime-dom': 3.3.7
'@vue/server-renderer': 3.3.7(vue@3.3.7)
'@vue/shared': 3.3.7
typescript: 4.7.4
/w3c-hr-time@1.0.2:
resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==}

View File

@@ -1 +1,2 @@
export * from "./s-3-link-controller";
export * from "./s-3-unlink-controller";

View File

@@ -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;
}

View File

@@ -0,0 +1,14 @@
import request from "@/utils/request";
import { DeepRequired } from "../../interface";
import { Attachment } from "@halo-dev/api-client";
/**
* /apis/s3os.halo.run/v1alpha1/attachments/{name}
*/
export function deleteApisS3OsHaloRunV1Alpha1AttachmentsByName(params: DeleteApisS3OsHaloRunV1Alpha1AttachmentsByNameParams) {
return request.delete<DeepRequired<Attachment>>(`/apis/s3os.halo.run/v1alpha1/attachments/${params.name}`);
}
interface DeleteApisS3OsHaloRunV1Alpha1AttachmentsByNameParams {
name: any;
}

View File

@@ -0,0 +1 @@
export * from "./deleteApisS3OsHaloRunV1Alpha1AttachmentsByName";

View File

@@ -1,19 +1,50 @@
import type { Attachment } from "@halo-dev/api-client";
import { definePlugin } from "@halo-dev/console-shared";
import type {PluginTab} 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 CarbonFolderDetailsReference from "~icons/carbon/folder-details-reference";
export default definePlugin({
components: {},
routes: [],
routes: [
{
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,
},
},
},
},
],
extensionPoints: {
"plugin:self:tabs:create": () : PluginTab[] => {
"attachment:list-item:operation:create": (attachment: Ref<Attachment>) => {
return [
{
id: "s3-link",
label: "关联S3文件",
component: markRaw(S3Link),
permissions: ["plugin:s3os:link"]
priority: 21,
component: markRaw(S3Unlink),
permissions: ["plugin:s3os:unlink"],
props: {
attachment: attachment,
},
hidden: !(
attachment.value.metadata.annotations &&
attachment.value.metadata.annotations[
"s3os.plugin.halo.run/object-key"
]
),
},
];
},

View File

@@ -1,6 +1,6 @@
{
"docsUrl": "http://localhost:8090/v3/api-docs/extension-api",
"includeTags": ["s-3-link-controller"],
"includeTags": ["s-3-link-controller", "s-3-unlink-controller"],
"excludeTags": [],
"axiosInstanceUrl": "@/utils/request",
"prefix": ""

View File

@@ -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);

View File

@@ -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>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import {deleteApisS3OsHaloRunV1Alpha1AttachmentsByName} from "@/controller";
import type {Attachment} from "@halo-dev/api-client";
import {Dialog, Toast, VDropdownDivider, VDropdownItem} from "@halo-dev/components";
import {useQueryClient} from "@tanstack/vue-query";
const props = defineProps<{
attachment: Attachment;
}>();
const queryClient = useQueryClient();
const handleUnlink = () => {
Dialog.warning({
title: "解除 S3 关联",
description: "解除关联后,附件中的记录将会被删除,而对象存储中的文件仍然保留,若需重新关联请使用“关联 S3 文件”功能。",
confirmType: "danger",
confirmText: "确定",
cancelText: "取消",
onConfirm: async () => {
try {
await deleteApisS3OsHaloRunV1Alpha1AttachmentsByName({name: props.attachment.metadata.name});
Toast.success("解除关联成功");
} catch (e) {
console.error("Failed to delete attachment", e);
} finally {
queryClient.invalidateQueries({queryKey: ["attachments"]});
}
},
});
}
</script>
<template>
<div>
<VDropdownDivider/>
<VDropdownItem type="danger" @click="handleUnlink">解除 S3 关联</VDropdownItem>
</div>
</template>

View File

@@ -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,
},
},
},
});

View File

@@ -1 +1 @@
version=1.6.1-SNAPSHOT
version=1.9.0-SNAPSHOT

View File

@@ -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;
}
}
/**

View File

@@ -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);
}
}

View File

@@ -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;
}

View 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;
}
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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() {
@@ -32,54 +24,23 @@ public class S3LinkController {
}
@GetMapping("/objects/{policyName}")
public Mono<S3ListResult> listObjects(@PathVariable String policyName,
public Mono<S3ListResult> listObjects(@PathVariable(value = "policyName") 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) {
@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());
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -37,6 +37,7 @@ import run.halo.app.core.extension.attachment.Policy;
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.extension.MetadataUtil;
import run.halo.app.infra.utils.JsonUtils;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.awscore.presigner.SdkPresigner;
@@ -66,6 +67,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
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 String SKIP_REMOTE_DELETION_ANNO = "s3os.plugin.halo.run/skip-remote-deletion";
public static final int MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024;
/**
@@ -80,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);
});
}
@@ -90,6 +93,13 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
.flatMap(context -> {
var objectKey = getObjectKey(context.attachment());
if (objectKey == null) {
log.warn(
"Cannot obtain object key from attachment {}, skip deleting object from S3.",
context.attachment().getMetadata().getName());
return Mono.just(context);
} else if (MetadataUtil.nullSafeAnnotations(context.attachment())
.containsKey(SKIP_REMOTE_DELETION_ANNO)) {
log.info("Skip deleting object {} from S3.", objectKey);
return Mono.just(context);
}
var properties = getProperties(deleteContext.configMap());
@@ -107,6 +117,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
})
.thenReturn(context);
})
.onErrorMap(S3ExceptionHandler::map)
.map(DeleteContext::attachment);
}
@@ -143,7 +154,8 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
}
},
SdkPresigner::close)
.subscribeOn(Schedulers.boundedElastic());
.subscribeOn(Schedulers.boundedElastic())
.onErrorMap(S3ExceptionHandler::map);
}
@Override
@@ -213,8 +225,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
var attachment = new Attachment();
attachment.setMetadata(metadata);
attachment.setSpec(spec);
log.info("Upload object {} to bucket {} successfully", objectDetail.uploadState.objectKey,
properties.getBucket());
log.info("Built attachment {} successfully", objectDetail.uploadState.objectKey);
return attachment;
}
@@ -365,6 +376,8 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
// build object detail
.map((response) -> {
checkResult(response, "getMetadata");
log.info("Uploaded object {} to bucket {} successfully",
uploadState.objectKey, properties.getBucket());
return new ObjectDetail(uploadState, response);
})
// close client
@@ -377,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
@@ -424,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()
@@ -444,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());
@@ -452,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();
@@ -497,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;
@@ -512,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);
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -0,0 +1,21 @@
package run.halo.s3os;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.plugin.ApiVersion;
@ApiVersion("s3os.halo.run/v1alpha1")
@RestController
@RequiredArgsConstructor
public class S3UnlinkController {
private final S3UnlinkService s3UnlinkService;
@DeleteMapping("/attachments/{name}")
public Mono<Attachment> unlink(@PathVariable("name") String name) {
return s3UnlinkService.unlink(name);
}
}

View File

@@ -0,0 +1,8 @@
package run.halo.s3os;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.attachment.Attachment;
public interface S3UnlinkService {
Mono<Attachment> unlink(String name);
}

View File

@@ -0,0 +1,36 @@
package run.halo.s3os;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ServerWebInputException;
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;
@Service
@RequiredArgsConstructor
@Slf4j
public class S3UnlinkServiceImpl implements S3UnlinkService {
private final ReactiveExtensionClient client;
private final S3OsAttachmentHandler handler;
@Override
public Mono<Attachment> unlink(String name) {
return client.get(Attachment.class, name)
.flatMap((attachment) -> client.get(Policy.class, attachment.getSpec().getPolicyName())
.doOnNext((policy) -> {
if (!handler.shouldHandle(policy)) {
throw new ServerWebInputException(
"This attachment policy is not managed by plugin-s3.");
}
}).thenReturn(attachment))
.flatMap(attachment -> {
attachment.getMetadata().getAnnotations().put(
S3OsAttachmentHandler.SKIP_REMOTE_DELETION_ANNO, Boolean.TRUE.toString());
return client.delete(attachment);
});
}
}

File diff suppressed because one or more lines are too long

View File

@@ -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: 绑定域名协议

View File

@@ -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: |
@@ -21,3 +21,35 @@ rules:
verbs: [ "get", "list" ]
- nonResourceURLs: ["/apis/s3os.halo.run/v1alpha1/attachments/link"]
verbs: [ "create" ]
---
apiVersion: v1alpha1
kind: "Role"
metadata:
name: role-template-s3os-unlink
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/dependencies: |
[ "role-template-manage-attachments" ]
rbac.authorization.halo.run/module: "S3 Attachments Management"
rbac.authorization.halo.run/display-name: "S3 UnLink"
rbac.authorization.halo.run/ui-permissions: |
["plugin:s3os:unlink"]
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" ]

View File

@@ -7,8 +7,5 @@ spec:
- group: basic
label: 使用提示
formSchema:
- $formkit: text
help: 请前往 “附件 - 存储策略” 添加策略
label: 此处不用设置,请前往 “附件 - 存储策略” 添加策略
name: text
placeholder: 此处不用设置,请前往 “附件 - 存储策略” 添加策略
- $el: p
children: 请前往 “附件 - 存储策略” 添加策略

View File

@@ -4,14 +4,16 @@ metadata:
name: PluginS3ObjectStorage
spec:
enabled: true
requires: ">=2.9.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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View 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}"));
}
}

View 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);
}
}