Skip to content

Commit

Permalink
Support WebDAV to backup data (#257, #314)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheepzh committed Sep 8, 2024
1 parent d328f8d commit 7898bd2
Show file tree
Hide file tree
Showing 31 changed files with 754 additions and 325 deletions.
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
"filemanager",
"Hengyang",
"Kanban",
"MKCOL",
"Openeds",
"Popconfirm",
"PROPFIND",
"Qihu",
"sheepzh",
"vueuse",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"countup.js": "^2.8.0",
"echarts": "^5.5.1",
"element-plus": "2.8.1",
"js-base64": "^3.7.7",
"punycode": "^2.3.1",
"stream-browserify": "^3.0.0",
"vue": "^3.4.38",
Expand All @@ -76,4 +77,4 @@
"engines": {
"node": ">=20"
}
}
}
113 changes: 113 additions & 0 deletions src/api/web-dav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* WebDAV client api
*
* Testing with server implemented by https://github.com/svtslv/webdav-cli
*/
import { encode } from 'js-base64'
import { fetchDelete, fetchGet } from './http'

// Only support password for now
export type WebDAVAuth = {
type: 'password'
username: string
password: string
}

export type WebDAVContext = {
auth: WebDAVAuth
endpoint: string
}

const authHeaders = (auth: WebDAVAuth): Headers => {
const type = auth?.type
let headerVal = null
if (type === 'password') {
headerVal = `Basic ${encode(`${auth?.username}:${auth?.password}`)}`
}
const headers = new Headers()
headers.set('Authorization', headerVal)
return headers
}

export async function judgeDirExist(context: WebDAVContext, dirPath: string): Promise<boolean> {
const { auth, endpoint } = context || {}
const headers = authHeaders(auth)
const url = `${endpoint}/${dirPath}`
const method = 'PROPFIND'
headers.append('Accept', 'text/plain,application/xml')
headers.append('Depth', '1')
const response = await fetch(url, { method, headers })
const status = response?.status
if (status == 207) {
return true
} else if (status === 300) {
throw new Error("Your server does't support PROPFIND method!")
} else if (status == 404) {
return false
} else if (status == 401) {
throw new Error("Authorization is invalid!")
} else {
throw new Error("Unknown directory status")
}
}

export async function makeDir(context: WebDAVContext, dirPath: string) {
const { auth, endpoint } = context || {}
const url = `${endpoint}/${dirPath}`
const headers = authHeaders(auth)
const response = await fetch(url, { method: 'MKCOL', headers })
handleWriteResponse(response)
}

export async function deleteDir(context: WebDAVContext, dirPath: string) {
const { auth, endpoint } = context || {}
const url = `${endpoint}/${dirPath}`
const headers = authHeaders(auth)
const response = await fetchDelete(url, { headers })
const status = response.status
if (status === 403) {
throw new Error("Unauthorized to delete directory")
}
if (status !== 201 && status !== 200) {
throw new Error("Failed to delete directory: " + status)
}
}

export async function writeFile(context: WebDAVContext, filePath: string, content: string): Promise<void> {
const { auth, endpoint } = context || {}
const headers = authHeaders(auth)
headers.set("Content-Type", "application/octet-stream")
const url = `${endpoint}/${filePath}`
const response = await fetch(url, { headers, method: 'put', body: content })
handleWriteResponse(response)
}

function handleWriteResponse(response: Response) {
const status = response.status
if (status === 403) {
throw new Error("Unauthorized to write file or create directory")
}
if (status !== 201 && status !== 200) {
throw new Error("Failed to write file or create directory: " + status)
}
}

export async function readFile(context: WebDAVContext, filePath: string): Promise<string> {
const { auth, endpoint } = context || {}
const headers = authHeaders(auth)
const url = `${endpoint}/${filePath}`
try {
const response = await fetchGet(url, { headers })
const status = response?.status
if (status === 200) {
return response.text()
}
if (status !== 404) {
console.warn("Unexpected status: " + status)
}
return null
} catch (e) {
console.error("Failed to read WebDAV content", e)
return null
}
}
13 changes: 6 additions & 7 deletions src/app/components/Option/components/BackupOption/AutoInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
* https://opensource.org/licenses/MIT
*/

import { t } from "@app/locale"
import { ElInputNumber, ElSwitch } from "element-plus"
import { defineComponent, watch } from "vue"
import localeMessages from "@i18n/message/common/locale"
import I18nNode from "@app/components/common/I18nNode"
import { t } from "@app/locale"
import { useShadow } from "@hooks"
import { locale } from "@i18n"
import localeMessages from "@i18n/message/common/locale"
import { ElInputNumber, ElSwitch } from "element-plus"
import { defineComponent, watch } from "vue"

const _default = defineComponent({
props: {
Expand All @@ -28,7 +28,7 @@ const _default = defineComponent({
return () => <>
<ElSwitch modelValue={autoBackUp.value} onChange={setAutoBackUp} />
{' ' + t(msg => msg.option.backup.auto.label)}
<div v-show={autoBackUp.value}>
{!!autoBackUp.value && <>
{localeMessages[locale].comma || ' '}
<I18nNode
path={msg => msg.option.backup.auto.interval}
Expand All @@ -41,8 +41,7 @@ const _default = defineComponent({
/>
}}
/>
</div>
<span v-show={autoBackUp.value}>{localeMessages[locale].comma || ' '}</span>
</>}
</>
},
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const _default = defineComponent({
<ElButton
type="danger"
icon={<Delete />}
style={{ marginRight: "12px" }}
onClick={() => dialogVisible.value = true}
>
{t(msg => msg.option.backup.clear.btn)}
Expand Down
78 changes: 41 additions & 37 deletions src/app/components/Option/components/BackupOption/ClientTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

import { t } from "@app/locale"
import { cvt2LocaleTime } from "@app/util/time"
import { Loading, RefreshRight } from "@element-plus/icons-vue"
import { useRequest } from "@hooks"
import metaService from "@service/meta-service"
import processor from "@src/common/backup/processor"
import { ElTableRowScope } from "@src/element-ui/table"
import { ElMessage, ElRadio, ElTable, ElTableColumn, ElTag } from "element-plus"
import { defineComponent, ref, Ref, onMounted } from "vue"
import { ElLink, ElMessage, ElRadio, ElTable, ElTableColumn, ElTag } from "element-plus"
import { defineComponent, ref } from "vue"

const formatTime = (value: timer.backup.Client): string => {
const { minDate, maxDate } = value || {}
Expand All @@ -25,52 +27,56 @@ const _default = defineComponent({
select: (_: timer.backup.Client) => true,
},
setup(_, ctx) {
const list: Ref<timer.backup.Client[]> = ref([])
const loading: Ref<boolean> = ref(false)
const selectedCid: Ref<string> = ref()
const localCid: Ref<string> = ref()

onMounted(() => {
loading.value = true
processor.listClients()
.then(res => {
if (res.success) {
list.value = res.data
} else {
throw res.errorMsg
}
})
.catch(e => ElMessage.error(typeof e === 'string' ? e : (e as Error).message || 'Unknown error...'))
.finally(() => loading.value = false)
metaService.getCid().then(cid => localCid.value = cid)
const { data: list, loading, refresh } = useRequest(async () => {
const { success, data, errorMsg } = await processor.listClients() || {}
if (!success) {
throw new Error(errorMsg)
}
return data
}, {
defaultValue: [],
onError: e => ElMessage.error(typeof e === 'string' ? e : (e as Error).message || 'Unknown error...')
})

const { data: localCid } = useRequest(() => metaService.getCid())

const selectedCid = ref<string>()
const handleRowSelect = (row: timer.backup.Client) => {
selectedCid.value = row.id
ctx.emit("select", row)
}

return () => (
<ElTable data={list.value}
<ElTable
data={list.value}
border
maxHeight="40vh"
class="backup-client-table"
highlightCurrentRow
onCurrent-change={(row: timer.backup.Client) => handleRowSelect(row)}
emptyText={loading.value ? 'Loading data ...' : 'Empty data'}
>
<ElTableColumn align="center" width={50}>
{
({ row }: ElTableRowScope<timer.backup.Client>) => (
<ElTableColumn
align="center"
width={50}
v-slots={{
header: () => (
<ElLink
icon={loading.value ? <Loading /> : <RefreshRight />}
onClick={refresh}
type="primary"
underline={false}
/>
),
default: ({ row }: ElTableRowScope<timer.backup.Client>) => (
<ElRadio
label={row.id}
value={row.id}
modelValue={selectedCid.value}
onChange={() => handleRowSelect(row)}
v-slots={() => ''}
/>
)
}
</ElTableColumn>
),
}}
/>
<ElTableColumn
label="CID"
align="center"
Expand All @@ -83,14 +89,12 @@ const _default = defineComponent({
align="center"
headerAlign="center"
>
{
({ row: client }: ElTableRowScope<timer.backup.Client>) => <>
{client.name || '-'}
<ElTag v-show={localCid.value === client?.id} size="small" type="danger">
{t(msg => msg.option.backup.clientTable.current)}
</ElTag>
</>
}
{({ row: client }: ElTableRowScope<timer.backup.Client>) => <>
{client.name || '-'}
<ElTag v-show={localCid.value === client?.id} size="small" type="danger">
{t(msg => msg.option.backup.clientTable.current)}
</ElTag>
</>}
</ElTableColumn>
<ElTableColumn
label={t(msg => msg.option.backup.clientTable.dataRange)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Back, Check } from "@element-plus/icons-vue"
import { processImportedData } from "@service/components/import-processor"
import { renderResolutionFormItem } from "@app/components/common/imported/conflict"
import CompareTable from "@app/components/common/imported/CompareTable"
import { useRequest } from "@hooks/useRequest"

const _default = defineComponent({
props: {
Expand All @@ -27,23 +28,21 @@ const _default = defineComponent({
},
setup(props, ctx) {
const resolution: Ref<timer.imported.ConflictResolution> = ref()
const downloading: Ref<boolean> = ref(false)

const handleDownload = () => {
const { refresh: handleDownload, loading: downloading } = useRequest(async () => {
const resolutionVal = resolution.value
if (!resolutionVal) {
ElMessage.warning(t(msg => msg.dataManage.importOther.conflictNotSelected))
return
}
downloading.value = true
processImportedData(props.data, resolutionVal)
.then(() => {
ElMessage.success(t(msg => msg.operation.successMsg))
ctx.emit('download')
})
.catch()
.finally(() => downloading.value = false)
}
await processImportedData(props.data, resolutionVal)
}, {
manual: true,
onSuccess() {
ElMessage.success(t(msg => msg.operation.successMsg))
ctx.emit('download')
}
})
return () => <>
<ElAlert type="success" closable={false}>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const _default = defineComponent({
<ElButton
type="primary"
icon={<Files />}
style={{ marginRight: "12px" }}
onClick={() => dialogVisible.value = true}
>
{t(msg => msg.option.backup.download.btn)}
Expand Down
Loading

0 comments on commit 7898bd2

Please sign in to comment.