mirror of
https://github.com/ApfelTeeSaft/R2-Explorer.git
synced 2026-08-26 19:33:39 +00:00
Add folder deletion and bump itty-router-openapi
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { api } from "boot/axios";
|
||||
import { useMainStore } from "stores/main-store";
|
||||
|
||||
export const ROOT_FOLDER = "IA==" // IA== is a space
|
||||
|
||||
@@ -147,4 +148,82 @@ export const apiHandler = {
|
||||
onUploadProgress: callback
|
||||
})
|
||||
},
|
||||
fetchFile: async (bucket, prefix, delimiter = '/') => {
|
||||
const mainStore = useMainStore();
|
||||
let truncated = true
|
||||
let cursor = null
|
||||
let contentFiles = []
|
||||
let contentFolders = []
|
||||
|
||||
while (truncated) {
|
||||
const response = await api.get(`/buckets/${bucket}?include=customMetadata&include=httpMetadata`, {
|
||||
params: {
|
||||
delimiter: delimiter,
|
||||
prefix: prefix && prefix !== '/' ? encode(prefix) : '',
|
||||
cursor: cursor
|
||||
}
|
||||
})
|
||||
|
||||
truncated = response.data.truncated
|
||||
cursor = response.data.cursor
|
||||
|
||||
if (response.data.objects) {
|
||||
const files = response.data.objects.filter(function(obj) {
|
||||
return !(obj.key.endsWith('/') && delimiter !== '') && obj.key !== prefix // Remove selected folder when delimiter is defined
|
||||
}).map(function(obj) {
|
||||
const date = new Date(obj.uploaded)
|
||||
|
||||
return {
|
||||
...obj,
|
||||
hash: encode(obj.key),
|
||||
nameHash: encode(obj.key.replace(prefix, '')),
|
||||
name: obj.key.replace(prefix, ''),
|
||||
lastModified: timeSince(date),
|
||||
timestamp: date.getTime(),
|
||||
size: bytesToSize(obj.size),
|
||||
sizeRaw: obj.size,
|
||||
type: 'file',
|
||||
icon: 'article',
|
||||
color: 'grey',
|
||||
}
|
||||
}).filter(obj => {
|
||||
// Remove hidden files
|
||||
return !(mainStore.showHiddenFiles !== true && obj.name.startsWith('.'))
|
||||
})
|
||||
|
||||
for (const f of files) {
|
||||
contentFiles.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
if (response.data.delimitedPrefixes) {
|
||||
const folders = response.data.delimitedPrefixes.map(function (obj) {
|
||||
return {
|
||||
name: obj.replace(prefix, ''),
|
||||
hash: encode(obj.key),
|
||||
key: obj,
|
||||
lastModified: '--',
|
||||
timestamp: 0,
|
||||
size: '--',
|
||||
sizeRaw: 0,
|
||||
type: 'folder',
|
||||
icon: 'folder',
|
||||
color: 'orange',
|
||||
}
|
||||
}).filter(obj => {
|
||||
// Remove hidden files
|
||||
return !(mainStore.showHiddenFiles !== true && obj.name.startsWith('.'))
|
||||
})
|
||||
|
||||
for (const f of folders) {
|
||||
contentFolders.push(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...contentFolders,
|
||||
...contentFiles
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<q-dialog v-model="modal" persistent>
|
||||
<q-form
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<q-card style="min-width: 350px">
|
||||
<q-dialog v-model="modal" @hide="cancel">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-form
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="text-h6">New Folder Name</div>
|
||||
</q-card-section>
|
||||
@@ -15,11 +15,11 @@
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="Cancel" @click="cancel" />
|
||||
<q-btn flat label="Create" type="submit" />
|
||||
<q-btn flat label="Cancel" v-close-popup />
|
||||
<q-btn flat label="Create" type="submit" :loading="loading" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-form>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
@@ -29,44 +29,49 @@ import { useMainStore } from "stores/main-store";
|
||||
import { apiHandler, decode, ROOT_FOLDER } from "src/appUtils";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CreateFolder',
|
||||
data: function () {
|
||||
name: "CreateFolder",
|
||||
data: function() {
|
||||
return {
|
||||
modal: false,
|
||||
newFolderName: ''
|
||||
}
|
||||
newFolderName: "",
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
cancel: function() {
|
||||
this.modal = false
|
||||
this.newFolderName = ''
|
||||
this.modal = false;
|
||||
this.newFolderName = "";
|
||||
this.loading = false
|
||||
},
|
||||
onSubmit: async function() {
|
||||
await apiHandler.createFolder(this.selectedFolder + this.newFolderName + '/', this.selectedBucket)
|
||||
this.$bus.emit('fetchFiles')
|
||||
this.modal = false
|
||||
this.loading = true
|
||||
await apiHandler.createFolder(this.selectedFolder + this.newFolderName + "/", this.selectedBucket);
|
||||
this.$bus.emit("fetchFiles");
|
||||
this.loading = false
|
||||
this.modal = false;
|
||||
this.newFolderName = "";
|
||||
},
|
||||
open: function() {
|
||||
this.modal = true
|
||||
this.modal = true;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedBucket: function () {
|
||||
return this.$route.params.bucket
|
||||
selectedBucket: function() {
|
||||
return this.$route.params.bucket;
|
||||
},
|
||||
selectedFolder: function () {
|
||||
selectedFolder: function() {
|
||||
if (this.$route.params.folder && this.$route.params.folder !== ROOT_FOLDER) {
|
||||
return decode(this.$route.params.folder)
|
||||
return decode(this.$route.params.folder);
|
||||
}
|
||||
return ''
|
||||
},
|
||||
return "";
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const mainStore = useMainStore();
|
||||
|
||||
return {
|
||||
mainStore,
|
||||
mainStore
|
||||
};
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<q-dialog v-model="deleteModal" @hide="deleteReset">
|
||||
<q-card>
|
||||
<q-card-section class="row column" v-if="deleteRow">
|
||||
<q-avatar class="q-mb-md" icon="delete" color="red" text-color="white" />
|
||||
<span v-if="deleteRow.type === 'folder'" class="q-ml-sm">Are you sure you want to delete the folder <code>{{deleteRow.name}}</code>, and
|
||||
<code v-if="deleteFolderInnerFilesCount !== null">{{deleteFolderInnerFilesCount}}</code>
|
||||
<code v-else><q-spinner color="primary"/></code>
|
||||
files inside?</span>
|
||||
<span v-else class="q-ml-sm">Are you sure you want to delete the file <code>{{deleteRow.name}}</code>?</span>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" color="primary" v-close-popup />
|
||||
<q-btn flat label="Delete" color="red" @click="deleteConfirm" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import { useMainStore } from "stores/main-store";
|
||||
import { apiHandler, decode, encode, ROOT_FOLDER } from "src/appUtils";
|
||||
import { useQuasar } from "quasar";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileOptions',
|
||||
data: function () {
|
||||
return {
|
||||
deleteRow: null,
|
||||
deleteFolderContents: [],
|
||||
deleteModal: false,
|
||||
deleteFolderInnerFilesCount: null,
|
||||
newFolderName: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
deleteObject: async function(row) {
|
||||
this.deleteModal = true
|
||||
this.deleteRow = row
|
||||
if (row.type === 'folder') {
|
||||
this.deleteFolderContents = await apiHandler.fetchFile(this.selectedBucket, row.key, '')
|
||||
this.deleteFolderInnerFilesCount = this.deleteFolderContents.length
|
||||
}
|
||||
},
|
||||
deleteConfirm: async function() {
|
||||
this.deleteModal = false
|
||||
|
||||
if (this.deleteRow.type === 'folder') {
|
||||
const notif = this.q.notify({
|
||||
group: false,
|
||||
spinner: true,
|
||||
message: 'Deleting files...',
|
||||
caption: '0%',
|
||||
timeout: 0
|
||||
})
|
||||
|
||||
console.log(this.deleteFolderContents)
|
||||
for (const [i, innerFile] of this.deleteFolderContents.entries()) {
|
||||
if (innerFile.key) {
|
||||
await apiHandler.deleteObject(innerFile.key, this.selectedBucket)
|
||||
}
|
||||
notif({
|
||||
caption: `${parseInt(i*100/(this.deleteFolderInnerFilesCount+1))}%` // +1 because still needs to delete the folder
|
||||
})
|
||||
}
|
||||
await apiHandler.deleteObject(this.deleteRow.key, this.selectedBucket)
|
||||
|
||||
notif({
|
||||
icon: 'done', // we add an icon
|
||||
spinner: false, // we reset the spinner setting so the icon can be displayed
|
||||
caption: '100%',
|
||||
message: 'Folder deleted!',
|
||||
timeout: 2500 // we will timeout it in 2.5s
|
||||
})
|
||||
} else {
|
||||
await apiHandler.deleteObject(this.deleteRow.key, this.selectedBucket)
|
||||
this.q.notify({
|
||||
group: false,
|
||||
icon: 'done', // we add an icon
|
||||
spinner: false, // we reset the spinner setting so the icon can be displayed
|
||||
message: 'File deleted!',
|
||||
timeout: 2500 // we will timeout it in 2.5s
|
||||
})
|
||||
}
|
||||
|
||||
this.$bus.emit('fetchFiles')
|
||||
this.deleteReset()
|
||||
},
|
||||
deleteReset: function() {
|
||||
this.deleteModal = false
|
||||
this.deleteRow = null
|
||||
this.deleteFolderInnerFilesCount = null
|
||||
this.deleteFolderContents = []
|
||||
},
|
||||
onSubmit: async function() {
|
||||
await apiHandler.createFolder(this.selectedFolder + this.newFolderName + '/', this.selectedBucket)
|
||||
this.$bus.emit('fetchFiles')
|
||||
this.modal = false
|
||||
},
|
||||
open: function() {
|
||||
this.modal = true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedBucket: function () {
|
||||
return this.$route.params.bucket
|
||||
},
|
||||
selectedFolder: function () {
|
||||
if (this.$route.params.folder && this.$route.params.folder !== ROOT_FOLDER) {
|
||||
return decode(this.$route.params.folder)
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
mainStore: useMainStore(),
|
||||
q: useQuasar()
|
||||
};
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
code {
|
||||
background-color: #e9e9e9;
|
||||
padding: 0.25em;
|
||||
}
|
||||
</style>
|
||||
@@ -211,7 +211,7 @@ export default {
|
||||
params: {
|
||||
bucket: this.$route.params.bucket,
|
||||
folder: this.$route.params.folder || ROOT_FOLDER,
|
||||
file: file.hash
|
||||
file: file.nameHash
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
:hide-pagination="true"
|
||||
:rows-per-page-options="[0]"
|
||||
column-sort-order="da"
|
||||
:flat="true"
|
||||
@rowClick="rowClick">
|
||||
:flat="true">
|
||||
|
||||
<template v-slot:body-cell-name="prop">
|
||||
<td class="flex" style="align-items: center">
|
||||
@@ -27,14 +26,23 @@
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-options="">
|
||||
<template v-slot:body-cell-options="prop">
|
||||
<td class="text-right">
|
||||
<q-btn round flat icon="more_vert" size="sm">
|
||||
<q-menu>
|
||||
<q-list style="min-width: 100px">
|
||||
<q-item clickable v-close-popup> <!-- @click="rowClick(prop.row)" -->
|
||||
<q-item clickable v-close-popup @click="openObject(prop.row)">
|
||||
<q-item-section>Open</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="downloadObject(prop.row)" v-if="prop.row.type === 'file'">
|
||||
<q-item-section>Download</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="shareObject(prop.row)">
|
||||
<q-item-section>Get sharable link</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="$refs.options.deleteObject(prop.row)">
|
||||
<q-item-section>Delete</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup>
|
||||
<q-item-section>New incognito tab</q-item-section>
|
||||
</q-item>
|
||||
@@ -52,19 +60,22 @@
|
||||
</q-page>
|
||||
|
||||
<file-preview ref="preview"/>
|
||||
<file-options ref="options" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import { api } from "boot/axios";
|
||||
import { useMainStore } from "stores/main-store";
|
||||
import { bytesToSize, decode, encode, ROOT_FOLDER, timeSince } from "../../appUtils";
|
||||
import { apiHandler, bytesToSize, decode, encode, ROOT_FOLDER, timeSince } from "../../appUtils";
|
||||
import FilePreview from "components/preview/FilePreview.vue";
|
||||
import DragAndDrop from "components/utils/DragAndDrop.vue";
|
||||
import FileOptions from "components/files/FileOptions.vue";
|
||||
import { useQuasar } from "quasar";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FilesIndexPage',
|
||||
components: { DragAndDrop, FilePreview },
|
||||
components: { FileOptions, DragAndDrop, FilePreview },
|
||||
data: function () {
|
||||
return {
|
||||
loading: false,
|
||||
@@ -177,83 +188,64 @@ export default defineComponent({
|
||||
this.$refs.preview.openFile(row)
|
||||
}
|
||||
},
|
||||
fetchFiles: async function () {
|
||||
const self = this
|
||||
this.loading = true
|
||||
let truncated = true
|
||||
let cursor = null
|
||||
let contentFiles = []
|
||||
let contentFolders = []
|
||||
|
||||
while (truncated) {
|
||||
const response = await api.get(`/buckets/${this.selectedBucket}?include=customMetadata&include=httpMetadata`, {
|
||||
openObject: function(row) {
|
||||
if (row.type === 'folder') {
|
||||
this.$router.push({ name: `files-folder`, params: { bucket: this.selectedBucket, folder: encode(row.key) }})
|
||||
} else {
|
||||
// console.log(row)
|
||||
this.$refs.preview.openFile(row)
|
||||
}
|
||||
},
|
||||
shareObject: async function(row) {
|
||||
let url
|
||||
if (row.type === 'folder') {
|
||||
url = window.location.origin + this.$router.resolve({
|
||||
name: 'files-folder',
|
||||
params: {
|
||||
delimiter: '/',
|
||||
prefix: this.selectedFolder && this.selectedFolder !== '/' ? encode(this.selectedFolder) : '',
|
||||
cursor: cursor
|
||||
bucket: this.selectedBucket,
|
||||
folder: encode(row.key)
|
||||
}
|
||||
})
|
||||
|
||||
truncated = response.data.truncated
|
||||
cursor = response.data.cursor
|
||||
|
||||
if (response.data.objects) {
|
||||
const files = response.data.objects.filter(function(obj) {
|
||||
return !obj.key.endsWith('/') // Remove selected folder
|
||||
}).map(function(obj) {
|
||||
const date = new Date(obj.uploaded)
|
||||
|
||||
return {
|
||||
...obj,
|
||||
hash: encode(obj.key),
|
||||
name: obj.key.replace(self.selectedFolder, ''),
|
||||
lastModified: timeSince(date),
|
||||
timestamp: date.getTime(),
|
||||
size: bytesToSize(obj.size),
|
||||
sizeRaw: obj.size,
|
||||
type: 'file',
|
||||
icon: 'article',
|
||||
color: 'grey',
|
||||
}
|
||||
}).filter(obj => {
|
||||
// Remove hidden files
|
||||
return !(this.mainStore.showHiddenFiles !== true && obj.name.startsWith('.'))
|
||||
})
|
||||
|
||||
for (const f of files) {
|
||||
contentFiles.push(f)
|
||||
}).href
|
||||
} else {
|
||||
url = window.location.origin + this.$router.resolve({
|
||||
name: 'files-file',
|
||||
params: {
|
||||
bucket: this.selectedBucket,
|
||||
folder: encode(this.selectedFolder || ROOT_FOLDER),
|
||||
file: row.nameHash
|
||||
}
|
||||
}
|
||||
|
||||
if (response.data.delimitedPrefixes) {
|
||||
const folders = response.data.delimitedPrefixes.map(function (obj) {
|
||||
return {
|
||||
name: obj.replace(self.selectedFolder, ''),
|
||||
hash: encode(obj.key),
|
||||
key: obj,
|
||||
lastModified: '--',
|
||||
timestamp: 0,
|
||||
size: '--',
|
||||
sizeRaw: 0,
|
||||
type: 'folder',
|
||||
icon: 'folder',
|
||||
color: 'orange',
|
||||
}
|
||||
}).filter(obj => {
|
||||
// Remove hidden files
|
||||
return !(this.mainStore.showHiddenFiles !== true && obj.name.startsWith('.'))
|
||||
})
|
||||
|
||||
for (const f of folders) {
|
||||
contentFolders.push(f)
|
||||
}
|
||||
}
|
||||
}).href
|
||||
}
|
||||
|
||||
this.rows = [
|
||||
...contentFolders,
|
||||
...contentFiles
|
||||
]
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.q.notify({
|
||||
message: 'Link to file copied to clipboard!',
|
||||
timeout: 5000,
|
||||
type: 'positive',
|
||||
})
|
||||
} catch (err) {
|
||||
this.q.notify({
|
||||
message: 'Failed to copy: ' + err,
|
||||
timeout: 5000,
|
||||
type: 'negative',
|
||||
})
|
||||
}
|
||||
},
|
||||
downloadObject: function(row) {
|
||||
const link = document.createElement('a')
|
||||
link.download = row.name
|
||||
|
||||
link.href = `${this.mainStore.serverUrl}/api/buckets/${this.selectedBucket}/${encode(row.key)}`
|
||||
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
},
|
||||
fetchFiles: async function () {
|
||||
this.loading = true
|
||||
|
||||
this.rows = await apiHandler.fetchFile(this.selectedBucket, this.selectedFolder, '/')
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
@@ -270,7 +262,8 @@ export default defineComponent({
|
||||
},
|
||||
setup () {
|
||||
return {
|
||||
mainStore: useMainStore()
|
||||
mainStore: useMainStore(),
|
||||
q: useQuasar()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,6 +12,12 @@ export const useMainStore = defineStore('main', {
|
||||
buckets: [],
|
||||
}),
|
||||
getters: {
|
||||
serverUrl() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return process.env.VUE_APP_SERVER_URL || 'http://localhost:8787'
|
||||
}
|
||||
return window.location.origin
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async loadUserDisks() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "R2 Explorer",
|
||||
"short_name": "R2 Explorer",
|
||||
"theme_color": "#4DBA87",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./img/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/android-chrome-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/android-chrome-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000"
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export class ListObjects extends OpenAPIRoute {
|
||||
parameters: {
|
||||
bucket: Path(String),
|
||||
limit: Query(Number, {required: false}),
|
||||
prefix: Query(z.string().optional().describe('base64 encoded prefix'),),
|
||||
cursor: Query(z.string().optional()),
|
||||
delimiter: Query(z.string().optional()),
|
||||
prefix: Query(z.string().nullable().optional().describe('base64 encoded prefix'),),
|
||||
cursor: Query(z.string().nullable().optional()),
|
||||
delimiter: Query(z.string().nullable().optional()),
|
||||
include: Query(z.string().array().optional()),
|
||||
},
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class ListObjects extends OpenAPIRoute {
|
||||
limit: data.query.limit,
|
||||
prefix: data.query.prefix ? decodeURIComponent(escape(atob(data.query.prefix))) : undefined,
|
||||
cursor: data.query.cursor,
|
||||
delimiter: data.query.delimiter,
|
||||
delimiter: (data.query.delimiter) ? data.query.delimiter : '',
|
||||
include: data.query.include,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export class CreateUpload extends OpenAPIRoute {
|
||||
parameters: {
|
||||
bucket: Path(String),
|
||||
key: Query(z.string().describe('base64 encoded file key')),
|
||||
customMetadata: Query(z.string().optional().describe('base64 encoded json string')),
|
||||
httpMetadata: Query(z.string().optional().describe('base64 encoded json string')),
|
||||
customMetadata: Query(z.string().nullable().optional().describe('base64 encoded json string')),
|
||||
httpMetadata: Query(z.string().nullable().optional().describe('base64 encoded json string')),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export class PutObject extends OpenAPIRoute {
|
||||
parameters: {
|
||||
bucket: Path(String),
|
||||
key: Query(z.string().describe('base64 encoded file key')),
|
||||
customMetadata: Query(z.string().optional().describe('base64 encoded json string')),
|
||||
httpMetadata: Query(z.string().optional().describe('base64 encoded json string')),
|
||||
customMetadata: Query(z.string().nullable().optional().describe('base64 encoded json string')),
|
||||
httpMetadata: Query(z.string().nullable().optional().describe('base64 encoded json string')),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user