7 Commits

Author SHA1 Message Date
Leo Vasanko
76659c6cdb Remove overly eager Gallery automode. No longer switches to gallery when merely changing sort column. 2025-09-30 19:23:24 -06:00
Leo Vasanko
a44a50878c Improved reliability of direct to folder downloads. 2025-09-30 19:18:30 -06:00
Leo Vasanko
b8816d482c Fix admin UI password reset and user deletion functions. 2025-09-30 18:31:20 -06:00
cfc80d2462 Implement web-based user management / admin setup. (#8)
Implement Admin Settings dialog for user management and toggling the public server flag, not needing CLI for maintenance anymore.
2025-10-01 01:10:33 +01:00
Leo Vasanko
bf604334bd Fix Vue TypeScript module declarations for build 2025-09-30 17:32:14 -06:00
Leo Vasanko
3edf595983 Clean up the remaining uses of print() 2025-08-18 10:49:40 -06:00
Leo Vasanko
6aef2e1208 Use zstd rather than brotli for static file compression. 2025-08-17 16:51:46 -06:00
13 changed files with 513 additions and 49 deletions

View File

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

View File

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

View File

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

@@ -1 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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