Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76659c6cdb | ||
|
|
a44a50878c | ||
|
|
b8816d482c | ||
| cfc80d2462 | |||
|
|
bf604334bd | ||
|
|
3edf595983 | ||
|
|
6aef2e1208 |
27
cista/app.py
27
cista/app.py
@@ -9,7 +9,6 @@ from stat import S_IFDIR, S_IFREG
|
||||
from urllib.parse import unquote
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
import brotli
|
||||
import sanic.helpers
|
||||
from blake3 import blake3
|
||||
from sanic import Blueprint, Sanic, empty, raw, redirect
|
||||
@@ -17,6 +16,7 @@ from sanic.exceptions import Forbidden, NotFound
|
||||
from sanic.log import logger
|
||||
from setproctitle import setproctitle
|
||||
from stream_zip import ZIP_AUTO, stream_zip
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from cista import auth, config, preview, session, watching
|
||||
from cista.api import bp
|
||||
@@ -95,6 +95,7 @@ def _load_wwwroot(www):
|
||||
wwwnew = {}
|
||||
base = Path(__file__).with_name("wwwroot")
|
||||
paths = [PurePath()]
|
||||
zstd = ZstdCompressor(level=10)
|
||||
while paths:
|
||||
path = paths.pop(0)
|
||||
current = base / path
|
||||
@@ -126,11 +127,11 @@ def _load_wwwroot(www):
|
||||
else "no-cache",
|
||||
"content-type": mime,
|
||||
}
|
||||
# Precompress with Brotli
|
||||
br = brotli.compress(data)
|
||||
if len(br) >= len(data):
|
||||
br = False
|
||||
wwwnew[name] = data, br, headers
|
||||
# Precompress with ZSTD
|
||||
zs = zstd.compress(data)
|
||||
if len(zs) >= len(data):
|
||||
zs = False
|
||||
wwwnew[name] = data, zs, headers
|
||||
if not wwwnew:
|
||||
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
||||
if not www:
|
||||
@@ -182,9 +183,9 @@ async def refresh_wwwroot():
|
||||
for name in sorted(set(wwwold) - set(www)):
|
||||
changes += f"Deleted /{name}\n"
|
||||
if changes:
|
||||
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
||||
logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
||||
except Exception as e:
|
||||
print(f"Error loading wwwroot: {e!r}")
|
||||
logger.error(f"Error loading wwwroot: {e!r}")
|
||||
await asyncio.sleep(0.5)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -196,14 +197,14 @@ async def wwwroot(req, path=""):
|
||||
name = unquote(path)
|
||||
if name not in www:
|
||||
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
||||
data, br, headers = www[name]
|
||||
data, zs, headers = www[name]
|
||||
if req.headers.if_none_match == headers["etag"]:
|
||||
# The client has it cached, respond 304 Not Modified
|
||||
return empty(304, headers=headers)
|
||||
# Brotli compressed?
|
||||
if br and "br" in req.headers.accept_encoding.split(", "):
|
||||
headers = {**headers, "content-encoding": "br"}
|
||||
data = br
|
||||
# Zstandard compressed?
|
||||
if zs and "zstd" in req.headers.accept_encoding.split(", "):
|
||||
headers = {**headers, "content-encoding": "zstd"}
|
||||
data = zs
|
||||
return raw(data, headers=headers)
|
||||
|
||||
|
||||
|
||||
101
cista/auth.py
101
cista/auth.py
@@ -10,6 +10,7 @@ from sanic import Blueprint, html, json, redirect
|
||||
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
||||
|
||||
from cista import config, session
|
||||
from cista.util import pwgen
|
||||
|
||||
_argon = argon2.PasswordHasher()
|
||||
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
||||
@@ -191,3 +192,103 @@ async def change_password(request):
|
||||
res = json({"message": "Password updated"})
|
||||
session.create(res, username)
|
||||
return res
|
||||
|
||||
|
||||
@bp.get("/users")
|
||||
async def list_users(request):
|
||||
verify(request, privileged=True)
|
||||
users = []
|
||||
for name, user in config.config.users.items():
|
||||
users.append(
|
||||
{
|
||||
"username": name,
|
||||
"privileged": user.privileged,
|
||||
"lastSeen": user.lastSeen,
|
||||
}
|
||||
)
|
||||
return json({"users": users})
|
||||
|
||||
|
||||
@bp.post("/users")
|
||||
async def create_user(request):
|
||||
verify(request, privileged=True)
|
||||
try:
|
||||
if request.headers.content_type == "application/json":
|
||||
username = request.json["username"]
|
||||
password = request.json.get("password")
|
||||
privileged = request.json.get("privileged", False)
|
||||
else:
|
||||
username = request.form["username"][0]
|
||||
password = request.form.get("password", [None])[0]
|
||||
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
|
||||
if not username or not username.isidentifier():
|
||||
raise ValueError("Invalid username")
|
||||
except (KeyError, ValueError) as e:
|
||||
raise BadRequest(str(e)) from e
|
||||
if username in config.config.users:
|
||||
raise BadRequest("User already exists")
|
||||
if not password:
|
||||
password = pwgen.generate()
|
||||
changes = {"privileged": privileged}
|
||||
changes["hash"] = _argon.hash(_pwnorm(password))
|
||||
try:
|
||||
config.update_user(username, changes)
|
||||
except Exception as e:
|
||||
raise BadRequest(str(e)) from e
|
||||
return json({"message": f"User {username} created", "password": password})
|
||||
|
||||
|
||||
@bp.put("/users/<username>")
|
||||
async def update_user(request, username):
|
||||
verify(request, privileged=True)
|
||||
try:
|
||||
if request.headers.content_type == "application/json":
|
||||
changes = request.json
|
||||
else:
|
||||
changes = {}
|
||||
if "password" in request.form:
|
||||
changes["password"] = request.form["password"][0]
|
||||
if "privileged" in request.form:
|
||||
changes["privileged"] = request.form["privileged"][0].lower() == "true"
|
||||
except KeyError as e:
|
||||
raise BadRequest("Missing fields") from e
|
||||
password_response = None
|
||||
if "password" in changes:
|
||||
if changes["password"] == "":
|
||||
changes["password"] = pwgen.generate()
|
||||
password_response = changes["password"]
|
||||
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
|
||||
del changes["password"]
|
||||
if not changes:
|
||||
return json({"message": "No changes"})
|
||||
try:
|
||||
config.update_user(username, changes)
|
||||
except Exception as e:
|
||||
raise BadRequest(str(e)) from e
|
||||
response = {"message": f"User {username} updated"}
|
||||
if password_response:
|
||||
response["password"] = password_response
|
||||
return json(response)
|
||||
|
||||
|
||||
@bp.delete("/users/<username>")
|
||||
async def delete_user(request, username):
|
||||
verify(request, privileged=True)
|
||||
if username not in config.config.users:
|
||||
raise BadRequest("User does not exist")
|
||||
try:
|
||||
config.del_user(username)
|
||||
except Exception as e:
|
||||
raise BadRequest(str(e)) from e
|
||||
return json({"message": f"User {username} deleted"})
|
||||
|
||||
|
||||
@bp.put("/config/public")
|
||||
async def update_public(request):
|
||||
verify(request, privileged=True)
|
||||
try:
|
||||
public = request.json["public"]
|
||||
except KeyError:
|
||||
raise BadRequest("Missing public field") from None
|
||||
config.update_config({"public": public})
|
||||
return json({"message": "Public setting updated"})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from time import monotonic
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class LRUCache:
|
||||
@@ -12,7 +13,7 @@ class LRUCache:
|
||||
cache (list): Internal list storing the cache items.
|
||||
"""
|
||||
|
||||
def __init__(self, open: callable, *, capacity: int, maxage: float):
|
||||
def __init__(self, open: Callable, *, capacity: int, maxage: float):
|
||||
"""
|
||||
Initialize LRUCache.
|
||||
|
||||
@@ -50,7 +51,6 @@ class LRUCache:
|
||||
# Add/restore to end of cache
|
||||
self.cache.insert(0, (key, f, monotonic()))
|
||||
self.expire_items()
|
||||
print(self.cache)
|
||||
return f
|
||||
|
||||
def expire_items(self):
|
||||
|
||||
6
frontend/env.d.ts
vendored
6
frontend/env.d.ts
vendored
@@ -1 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<LoginModal />
|
||||
<SettingsModal />
|
||||
<UserManagementModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
@@ -28,6 +29,7 @@ import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||
import UserManagementModal from './components/UserManagementModal.vue'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
|
||||
@@ -78,7 +78,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create directory', hdir, error)
|
||||
return
|
||||
throw new Error(`Failed to create directory ${hdir}: ${error}`)
|
||||
}
|
||||
console.log('Created', hdir)
|
||||
}
|
||||
@@ -90,37 +90,42 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||
fileHandle = await h.getFileHandle(name, { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||
return
|
||||
throw new Error(`Failed to create file ${hdir + name}: ${error}`)
|
||||
}
|
||||
const writable = await fileHandle.createWritable()
|
||||
const url = `/files/${rel}`
|
||||
console.log('Fetching', url)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if (res.body) {
|
||||
++store.dprogress.fileidx
|
||||
const reader = res.body.getReader()
|
||||
await writable.truncate(0)
|
||||
store.error = "Direct download."
|
||||
store.dprogress.tlast = Date.now()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
await writable.write(value)
|
||||
const now = Date.now()
|
||||
const size = value.byteLength
|
||||
store.dprogress.xfer += size
|
||||
store.dprogress.filepos += size
|
||||
store.dprogress.statbytes += size
|
||||
store.dprogress.statdur += now - store.dprogress.tlast
|
||||
store.dprogress.tlast = now
|
||||
try {
|
||||
const writable = await fileHandle.createWritable()
|
||||
const url = `/files/${rel}`
|
||||
console.log('Fetching', url)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if (res.body) {
|
||||
++store.dprogress.fileidx
|
||||
const reader = res.body.getReader()
|
||||
await writable.truncate(0)
|
||||
store.error = "Direct download."
|
||||
store.dprogress.tlast = Date.now()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
await writable.write(value)
|
||||
const now = Date.now()
|
||||
const size = value.byteLength
|
||||
store.dprogress.xfer += size
|
||||
store.dprogress.filepos += size
|
||||
store.dprogress.statbytes += size
|
||||
store.dprogress.statdur += now - store.dprogress.tlast
|
||||
store.dprogress.tlast = now
|
||||
}
|
||||
}
|
||||
await writable.close()
|
||||
console.log('Saved', hdir + name)
|
||||
} catch (error) {
|
||||
console.error('Failed to write file', hdir + name, error)
|
||||
throw new Error(`Failed to write file ${hdir + name}: ${error}`)
|
||||
}
|
||||
await writable.close()
|
||||
console.log('Saved', hdir + name)
|
||||
}
|
||||
statReset()
|
||||
}
|
||||
|
||||
@@ -73,7 +73,10 @@ watchEffect(() => {
|
||||
const settingsMenu = (e: Event) => {
|
||||
// show the context menu
|
||||
const items = []
|
||||
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }})
|
||||
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
|
||||
if (store.user.privileged) {
|
||||
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
||||
}
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
||||
} else {
|
||||
|
||||
265
frontend/src/components/UserManagementModal.vue
Normal file
265
frontend/src/components/UserManagementModal.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<ModalDialog name=usermgmt title="Admin Settings">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else>
|
||||
<h3>Server Settings</h3>
|
||||
<div class="form-row">
|
||||
<input
|
||||
id="publicServer"
|
||||
type="checkbox"
|
||||
v-model="serverSettings.public"
|
||||
@change="updateServerSettings"
|
||||
/>
|
||||
<label for="publicServer">Publicly accessible without any user account.</label>
|
||||
</div>
|
||||
<h3>Users</h3>
|
||||
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
||||
<div v-if="success" class="success-message" @click="copySuccess(false)">
|
||||
{{ success }}
|
||||
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
|
||||
</div>
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Admin</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.username">
|
||||
<td>{{ user.username }}</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="user.privileged"
|
||||
@change="toggleAdmin(user, $event)"
|
||||
:disabled="user.username === store.user.username"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button @click="renameUser(user)" class="button small" title="Rename user">✏️</button>
|
||||
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
|
||||
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="close" class="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
privileged: boolean
|
||||
lastSeen: number
|
||||
}
|
||||
|
||||
const store = useMainStore()
|
||||
const loading = ref(true)
|
||||
const users = ref<User[]>([])
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const copyButtonText = ref('📋')
|
||||
const serverSettings = reactive({
|
||||
public: false
|
||||
})
|
||||
|
||||
const close = () => {
|
||||
store.dialog = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await listUsers()
|
||||
users.value = data.users
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
error.value = httpError.message || 'Failed to load users'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addUser = async () => {
|
||||
const username = window.prompt('Enter username for new user:')
|
||||
if (!username || !username.trim()) return
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
const result = await createUser(username.trim(), undefined, false)
|
||||
await loadUsers()
|
||||
if (result.password) {
|
||||
success.value = `User ${username.trim()} created. Password: ${result.password}`
|
||||
}
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
error.value = httpError.message || 'Failed to add user'
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAdmin = async (user: User, event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
try {
|
||||
error.value = ''
|
||||
await updateUser(user.username, { privileged: target.checked })
|
||||
user.privileged = target.checked
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
error.value = httpError.message || 'Failed to update user'
|
||||
target.checked = user.privileged // revert
|
||||
}
|
||||
}
|
||||
|
||||
const renameUser = async (user: User) => {
|
||||
const newName = window.prompt('Enter new username:', user.username)
|
||||
if (!newName || !newName.trim() || newName.trim() === user.username) return
|
||||
// For rename, we need to create new user and delete old, or have a rename endpoint
|
||||
// Since no rename endpoint, perhaps delete and create
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
const result = await createUser(newName.trim(), undefined, user.privileged)
|
||||
await deleteUser(user.username)
|
||||
await loadUsers()
|
||||
if (result.password) {
|
||||
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
|
||||
}
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
error.value = httpError.message || 'Failed to rename user'
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async (user: User) => {
|
||||
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
const result = await updateUser(user.username, { password: "" })
|
||||
if (result.password) {
|
||||
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||||
}
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
error.value = httpError.message || 'Failed to reset password'
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUserAction = async (username: string) => {
|
||||
if (!confirm(`Delete user ${username}?`)) return
|
||||
try {
|
||||
error.value = ''
|
||||
await deleteUser(username)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
error.value = httpError.message || 'Failed to delete user'
|
||||
}
|
||||
}
|
||||
|
||||
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
||||
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!')
|
||||
success.value = baseMessage
|
||||
// Hide the entire message after 3 seconds
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
copyButtonText.value = '📋'
|
||||
}, 3000)
|
||||
} else {
|
||||
// Just hide the message when clicking elsewhere
|
||||
success.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateServerSettings = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
await updatePublic(serverSettings.public)
|
||||
// Update store
|
||||
store.server.public = serverSettings.public
|
||||
success.value = 'Server settings updated'
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
error.value = httpError.message || 'Failed to update settings'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
serverSettings.public = store.server.public
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
watch(() => store.server.public, (newVal) => {
|
||||
serverSettings.public = newVal
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.user-table th, .user-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
.user-table th {
|
||||
background: var(--soft-color);
|
||||
}
|
||||
.button.small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.button.danger {
|
||||
background: var(--red-color);
|
||||
color: white;
|
||||
}
|
||||
.button.danger:hover {
|
||||
background: #d00;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.form-row label {
|
||||
min-width: 100px;
|
||||
}
|
||||
.success-message {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,20 @@
|
||||
class ClientClass {
|
||||
async get(url: string): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/json'
|
||||
}
|
||||
})
|
||||
let msg
|
||||
try {
|
||||
msg = await res.json()
|
||||
} catch (e) {
|
||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
}
|
||||
async post(url: string, data?: Record<string, any>): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -17,6 +33,40 @@ class ClientClass {
|
||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
}
|
||||
async put(url: string, data?: Record<string, any>): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined
|
||||
})
|
||||
let msg
|
||||
try {
|
||||
msg = await res.json()
|
||||
} catch (e) {
|
||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
}
|
||||
async delete(url: string): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
accept: 'application/json'
|
||||
}
|
||||
})
|
||||
let msg
|
||||
try {
|
||||
msg = await res.json()
|
||||
} catch (e) {
|
||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
export const Client = new ClientClass()
|
||||
|
||||
@@ -24,3 +24,34 @@ export async function changePassword(username: string, passwordChange: string, p
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const url_users = '/users'
|
||||
|
||||
export async function listUsers() {
|
||||
const data = await Client.get(url_users)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUser(username: string, password?: string, privileged?: boolean) {
|
||||
const data = await Client.post(url_users, {
|
||||
username,
|
||||
password,
|
||||
privileged
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
|
||||
const data = await Client.put(`${url_users}/${username}`, changes)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteUser(username: string) {
|
||||
const data = await Client.delete(`${url_users}/${username}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updatePublic(publicFlag: boolean) {
|
||||
const data = await Client.put('/config/public', { public: publicFlag })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const useMainStore = defineStore({
|
||||
connected: false,
|
||||
cursor: '' as string,
|
||||
server: {} as Record<string, any>,
|
||||
dialog: '' as '' | 'login' | 'settings',
|
||||
dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
|
||||
uprogress: {} as any,
|
||||
dprogress: {} as any,
|
||||
prefs: {
|
||||
|
||||
@@ -73,8 +73,8 @@ watchEffect(() => {
|
||||
store.query = props.query
|
||||
})
|
||||
|
||||
watch(documents, (docs) => {
|
||||
store.prefs.gallery = docs.some(d => d.previewable)
|
||||
watch([() => props.path.join('/'), () => props.query], () => {
|
||||
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ dependencies = [
|
||||
"argon2-cffi>=25.1.0",
|
||||
"av>=15.0.0",
|
||||
"blake3>=1.0.5",
|
||||
"brotli>=1.1.0",
|
||||
"docopt>=0.6.2",
|
||||
"inotify>=0.2.12",
|
||||
"msgspec>=0.19.0",
|
||||
@@ -42,6 +41,7 @@ dependencies = [
|
||||
"setproctitle>=1.3.6",
|
||||
"stream-zip>=0.0.83",
|
||||
"tomli_w>=1.2.0",
|
||||
"zstandard>=0.24.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Reference in New Issue
Block a user