WebDAV sync support, access tokens, REST control endpoints (#10)

Implement complete WebDAV file serving compatible with various clients from Windows File Explorer to more specialized sync tools. The old control WebSocket has been updated to part-DAV, part REST API instead. Implemented user:pass BASIC auth. Added UI and backend for creating tokens that avoid the need to use actual username and password for requests from CLI or DAV.
This commit is contained in:
2026-04-26 04:22:52 +00:00
parent 9ac571463a
commit f8b2c9494a
31 changed files with 3377 additions and 529 deletions
+2
View File
@@ -7,6 +7,7 @@
</div>
<SettingsModal />
<UserManagementModal />
<UserTokensModal />
<AccessDeniedModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
@@ -35,6 +36,7 @@ import Router from '@/router/index'
import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import UserTokensModal from './components/UserTokensModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue'
+46 -59
View File
@@ -76,7 +76,7 @@ import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTic
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import { connect, controlUrl } from '@/repositories/WS'
import { apiFetch } from '@/repositories/Client'
import { formatSize } from '@/utils'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
@@ -87,31 +87,36 @@ const props = defineProps<{
}>()
const store = useMainStore()
const router = useRouter()
const filesUrl = (path: string) =>
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// File rename
const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => {
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker
try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch(
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) {
console.error('Rename failed', err)
doc.name = oldName
store.showToast(err instanceof Error ? err.message : 'Rename failed')
}
}
defineExpose({
newFolder() {
@@ -253,31 +258,20 @@ onMounted(() => {
}
})
onUnmounted(() => { clearInterval(modifiedTimer) })
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, {
open() {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.urlrouter)
}
}
})
const mkdir = async (doc: Doc, name: string) => {
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
const path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
router.push(doc.urlrouter)
} catch (err) {
console.error('Mkdir failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
}
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
@@ -373,24 +367,17 @@ const copyImage = async (doc: Doc) => {
}
}
const deleteFile = (doc: Doc) => {
const deleteFile = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
try {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
store.showToast(`🗑️ Deleted ${doc.name}`)
} catch (err) {
console.error('Delete failed', err)
store.unhideDoc(path)
store.showToast(err instanceof Error ? err.message : 'Delete failed')
}
}
+46 -59
View File
@@ -12,7 +12,7 @@
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS'
import { apiFetch } from '@/repositories/Client'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort'
@@ -23,32 +23,37 @@ const props = defineProps<{
}>()
const store = useMainStore()
const router = useRouter()
const filesUrl = (path: string) =>
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// File rename
const editing = shallowRef<Doc | null>(null)
const exit = () => { editing.value = null }
const rename = (doc: Doc, newName: string) => {
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker
try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch(
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) {
console.error('Rename failed', err)
doc.name = oldName
store.showToast(err instanceof Error ? err.message : 'Rename failed')
}
}
const gallery = ref<HTMLElement>()
const columnCount = ref(1)
@@ -202,31 +207,20 @@ onMounted(() => {
onUnmounted(() => {
resizeObserver?.disconnect()
})
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, {
open() {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.urlrouter)
}
}
})
const mkdir = async (doc: Doc, name: string) => {
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
const path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
router.push(doc.urlrouter)
} catch (err) {
console.error('Mkdir failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
}
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
@@ -312,24 +306,17 @@ const copyImage = async (doc: Doc) => {
}
}
const deleteFile = (doc: Doc) => {
const deleteFile = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
try {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
store.showToast(`🗑️ Deleted ${doc.name}`)
} catch (err) {
console.error('Delete failed', err)
store.unhideDoc(path)
store.showToast(err instanceof Error ? err.message : 'Delete failed')
}
}
+4
View File
@@ -105,6 +105,10 @@ const settingsMenu = (e: Event) => {
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
}
if (store.user.isLoggedIn) {
items.push({ label: '🔑 API Tokens', onClick: () => { store.dialog = 'tokens' }})
}
if (store.user.privileged) {
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
}
+40 -28
View File
@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS'
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { computed, ref } from 'vue'
import { formatSize } from '@/utils'
@@ -49,6 +49,18 @@ const navigateTo = (path: string) => {
router.push('/' + path)
}
const filesUrl = (path: string) =>
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// Truncate long names to reasonable length
const truncateName = (name: string, maxLen = 20): string => {
if (name.length <= maxLen) return name
@@ -115,43 +127,43 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
}
})
const op = (opName: string, dst?: string) => {
const op = async (opName: string, dst?: string) => {
const sel = store.selectedFiles
const keys = sel.keys
const paths = sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
const msg = {
op: opName,
sel: paths
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
// Hide items being deleted or moved (optimistic update)
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.hideDoc(path)
}
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
store.error = res.error.message
// Restore hidden items on error
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
store.selected.clear()
return
} else console.log('Unknown control response', msg, res)
try {
if (opName === 'rm') {
for (const path of paths) {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
}
} else if (opName === 'mv' || opName === 'cp') {
if (keys.length === 0) throw new Error('No selected files')
const dstUrl = dst ? filesUrl(dst) : '/files/'
const query = `${opName}=${keys.join('+')}`
const res = await apiFetch(`${dstUrl}?${query}`, { method: 'POST' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
} else {
throw new Error(`Unsupported operation: ${opName}`)
}
store.selected.clear()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('REST file operation failed', opName, err)
store.error = message
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
})
control.onopen = () => {
control.send(JSON.stringify(msg))
}
}
@@ -188,14 +188,14 @@ const deleteUserAction = async (username: string) => {
}
const copySuccess = async (isButtonClick: boolean = false) => {
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
const passwordMatch = success.value.match(/(?:Password|New password|Key): (.+)/)
if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1]!)
if (isButtonClick) {
// Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!'
// Hide password and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
// Hide password/key and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password|Key): .+/, 'Copied to clipboard!')
success.value = baseMessage
// Hide the entire message after 3 seconds
setTimeout(() => {
+264
View File
@@ -0,0 +1,264 @@
<template>
<ModalDialog name=tokens title="My API Tokens">
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<p class="hint">Create tokens to access Cista from scripts or other apps. Tokens are tied to your account.</p>
<!-- Creation form -->
<div v-if="mode === 'creating'" class="create-form">
<label for="token-name">Token name (optional)</label>
<input
id="token-name"
v-model="newTokenName"
type="text"
placeholder="e.g. backup-script"
@keyup.enter="submitCreate"
ref="nameInput"
/>
<div class="form-actions">
<button @click="submitCreate" class="button primary" :disabled="creating">Create</button>
<button @click="cancelCreate" class="button">Cancel</button>
</div>
</div>
<!-- Creation result -->
<div v-else-if="mode === 'created' && createdToken" class="created-result">
<p class="success-title"> Token created</p>
<p class="hint">Copy this URL it will not be shown again.</p>
<div class="url-box">
<code class="token-url">{{ createdToken.url }}</code>
<button @click="copyUrl" class="button small">{{ copyButtonText }}</button>
</div>
<p class="hint">Use it like: <code>curl {{ createdToken.url }}/...</code></p>
<div class="form-actions">
<button @click="finishCreate" class="button primary">Done</button>
</div>
</div>
<!-- Token list -->
<div v-else>
<button @click="startCreate" class="button" title="Add new token"> Add Token</button>
<table v-if="tokens.length">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="token in tokens" :key="token.id">
<td>{{ token.name || 'Unnamed' }}</td>
<td>{{ formatDate(token.created) }}</td>
<td>
<button @click="deleteTokenAction(token.id)" class="button small danger" title="Revoke token">🗑</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty">You have no API tokens.</p>
</div>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
</div>
</ModalDialog>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick } from 'vue'
import { listTokens, createToken, deleteToken } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
interface Token {
id: string
username: string
sso_user_id: string
name: string
created: number
}
interface CreatedToken extends Token {
key: string
url: string
}
const store = useMainStore()
const loading = ref(true)
const tokens = ref<Token[]>([])
const mode = ref<'list' | 'creating' | 'created'>('list')
const newTokenName = ref('')
const creating = ref(false)
const createdToken = ref<CreatedToken | null>(null)
const copyButtonText = ref('📋')
const nameInput = ref<HTMLInputElement | null>(null)
const close = () => {
store.dialog = ''
resetCreate()
}
const resetCreate = () => {
mode.value = 'list'
newTokenName.value = ''
creating.value = false
createdToken.value = null
copyButtonText.value = '📋'
}
const loadTokens = async () => {
try {
loading.value = true
const data = await listTokens()
tokens.value = data.tokens
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to load tokens')
} finally {
loading.value = false
}
}
const startCreate = () => {
mode.value = 'creating'
nextTick(() => nameInput.value?.focus())
}
const cancelCreate = () => {
resetCreate()
}
const ensureFilesBaseUrl = (url: string) => {
const trimmed = url.replace(/\/+$/, '')
if (trimmed.endsWith('/files')) return trimmed
return `${trimmed}/files`
}
const submitCreate = async () => {
if (creating.value) return
creating.value = true
try {
const result = await createToken(newTokenName.value)
await loadTokens()
if (result.url) {
createdToken.value = {
...(result as CreatedToken),
url: ensureFilesBaseUrl((result as CreatedToken).url),
}
mode.value = 'created'
}
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to create token')
mode.value = 'list'
} finally {
creating.value = false
}
}
const finishCreate = () => {
resetCreate()
}
const copyUrl = async () => {
if (!createdToken.value) return
await navigator.clipboard.writeText(createdToken.value.url)
copyButtonText.value = '✅ Copied!'
setTimeout(() => {
copyButtonText.value = '📋'
}, 2000)
}
const deleteTokenAction = async (tokenId: string) => {
if (!confirm('Revoke this token? It will no longer work.')) return
try {
await deleteToken(tokenId)
await loadTokens()
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to revoke token')
}
}
const formatDate = (ts: number) => {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString()
}
// Load tokens when dialog opens
watch(() => store.dialog, (newVal) => {
if (newVal === 'tokens') {
resetCreate()
loadTokens()
}
})
</script>
<style scoped>
.hint {
color: #666;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.empty {
color: #888;
font-style: italic;
margin: 1rem 0;
}
.create-form {
margin-bottom: 1rem;
}
.create-form label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #444;
}
.create-form input {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
margin-bottom: 0.5rem;
}
.create-form input:focus {
outline: none;
border-color: #f80;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.created-result {
margin-bottom: 1rem;
}
.success-title {
color: #080;
font-weight: bold;
margin: 0 0 0.5rem 0;
}
.url-box {
display: flex;
gap: 0.5rem;
align-items: center;
background: #f0f0f0;
padding: 0.75rem;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
.token-url {
flex: 1;
word-break: break-all;
font-size: 0.875rem;
color: #222;
}
.dialog-buttons {
margin-top: 1rem;
text-align: right;
}
</style>
+17
View File
@@ -65,3 +65,20 @@ export async function getServerConfig() {
const data = await Client.get('/api/config')
return data as { name: string, public: boolean }
}
export const url_tokens = '/api/tokens'
export async function listTokens() {
const data = await Client.get(url_tokens)
return data
}
export async function createToken(name: string) {
const data = await Client.post(url_tokens, { name })
return data
}
export async function deleteToken(tokenId: string) {
const data = await Client.delete(`${url_tokens}/${tokenId}`)
return data
}
-1
View File
@@ -2,7 +2,6 @@ import { useMainStore } from "@/stores/main"
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
export const controlUrl = '/api/control'
export const watchUrl = '/api/watch'
let tree = [] as FileEntry[]
+1 -1
View File
@@ -80,7 +80,7 @@ export const useMainStore = defineStore('main', {
authInProgress: false,
cursor: '' as string,
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
uprogress: {} as any,
dprogress: {} as any,
prefs: {