Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9aec6bb58 | ||
|
|
c79cb497ee | ||
|
|
9f50c8c20d | ||
|
|
816c7a681e | ||
|
|
d31c09084e | ||
|
|
cc938dd306 | ||
|
|
36db1e7e56 | ||
|
|
95c163e37a |
@@ -6,6 +6,7 @@ dist/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
paskia.sqlite
|
paskia.sqlite
|
||||||
*.paskiadb
|
*.paskiadb
|
||||||
|
*.data
|
||||||
/paskia/frontend-build
|
/paskia/frontend-build
|
||||||
/paskia/_version.py
|
/paskia/_version.py
|
||||||
coverage-html/
|
coverage-html/
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ paskia [options]
|
|||||||
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
||||||
| --save | Save current options to database | (only --rp-id required on further invocations) |
|
| --save | Save current options to database | (only --rp-id required on further invocations) |
|
||||||
|
|
||||||
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||||
|
|
||||||
## Tutorial: From Local Testing to Production
|
## Tutorial: From Local Testing to Production
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -26,13 +26,17 @@ The `validate` and `forward` endpoints take query arguments `perm=` and `max_age
|
|||||||
|
|
||||||
| Method | Path | Used for | Notes |
|
| Method | Path | Used for | Notes |
|
||||||
|---:|---|---|---|
|
|---:|---|---|---|
|
||||||
| PUT | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` |
|
| PATCH | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` |
|
||||||
|
| GET | `/auth/api/user/{uuid}/profile.webp` | Canonical avatar image URL | Public on the auth host; serves `image/webp` with `ETag` and short-lived cache headers |
|
||||||
|
| PUT | `/auth/api/user/{uuid}/profile.webp` | Upload or replace a user avatar | Multipart form with `file`; upload must already be square WebP prepared in the browser |
|
||||||
|
| DELETE | `/auth/api/user/{uuid}/profile.webp` | Remove a user avatar | Allowed for the user, master admin, or org admin for users in the same org |
|
||||||
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
|
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
|
||||||
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
|
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
|
||||||
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
|
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
|
||||||
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
|
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
|
||||||
|
|
||||||
These are used mostly from the user profile panel and modify the current user.
|
These are used mostly from the user profile panel. The avatar route is also used by admins when managing other users.
|
||||||
|
`GET /auth/api/user-info` includes `user.avatar_url` when the user has an uploaded avatar, using the same canonical `/auth/api/user/{uuid}/profile.webp` path.
|
||||||
|
|
||||||
### Admin API: `/auth/api/admin/*`
|
### Admin API: `/auth/api/admin/*`
|
||||||
|
|
||||||
@@ -72,6 +76,8 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
|||||||
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
|
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
|
||||||
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
|
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
|
||||||
|
|
||||||
|
Admins edit user avatars through the same canonical `/auth/api/user/{uuid}/profile.webp` PUT and DELETE endpoints.
|
||||||
|
|
||||||
### WebSockets: `/auth/ws/*`
|
### WebSockets: `/auth/ws/*`
|
||||||
|
|
||||||
| Path | Used for | Notes |
|
| Path | Used for | Notes |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execSync, spawn } from 'child_process'
|
import { execSync, spawn } from 'child_process'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -58,6 +58,11 @@ export default async function globalSetup() {
|
|||||||
// Use a fresh database file for tests
|
// Use a fresh database file for tests
|
||||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||||
|
|
||||||
|
if (existsSync(testDbFile)) {
|
||||||
|
console.log(' Removing stale test database...')
|
||||||
|
rmSync(testDbFile, { force: true, recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
// Start the server using Node's spawn
|
// Start the server using Node's spawn
|
||||||
const serverProcess = spawn('uv', serverArgs, {
|
const serverProcess = spawn('uv', serverArgs, {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default async function globalTeardown() {
|
|||||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||||
if (existsSync(testDbFile)) {
|
if (existsSync(testDbFile)) {
|
||||||
console.log(' Removing test database...')
|
console.log(' Removing test database...')
|
||||||
rmSync(testDbFile)
|
rmSync(testDbFile, { force: true, recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Python coverage report if coverage was collected
|
// Generate Python coverage report if coverage was collected
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -396,8 +397,19 @@ defineExpose({ focusFirstElement })
|
|||||||
@keydown.enter="$emit('openUser', u)"
|
@keydown.enter="$emit('openUser', u)"
|
||||||
:title="u.uuid"
|
:title="u.uuid"
|
||||||
>
|
>
|
||||||
|
<ProfilePicture
|
||||||
|
class="user-chip-picture"
|
||||||
|
:src="u.avatar_url"
|
||||||
|
:title="u.display_name"
|
||||||
|
width="3.25rem"
|
||||||
|
height="100%"
|
||||||
|
radius="0"
|
||||||
|
fallback-size="1.3rem"
|
||||||
|
/>
|
||||||
|
<span class="user-chip-body">
|
||||||
<span class="name">{{ u.display_name }}</span>
|
<span class="name">{{ u.display_name }}</span>
|
||||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
|
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
@@ -418,7 +430,7 @@ defineExpose({ focusFirstElement })
|
|||||||
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||||
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
||||||
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||||
.role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
.role-column { flex: 0 0 17em; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
||||||
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
||||||
.role-actions { display: flex; gap: var(--space-xs); }
|
.role-actions { display: flex; gap: var(--space-xs); }
|
||||||
@@ -426,9 +438,12 @@ defineExpose({ focusFirstElement })
|
|||||||
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
||||||
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
||||||
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); align-items: stretch; gap: 0; cursor: grab; overflow: hidden; min-height: 3.25rem; }
|
||||||
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||||
.user-chip .meta { font-size: 0.7rem; }
|
.user-chip-picture { align-self: stretch; }
|
||||||
|
.user-chip-body { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 0.1rem; padding: 0.45rem 0.6rem; }
|
||||||
|
.user-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.user-chip .meta { font-size: 0.7rem; opacity: 0.85; }
|
||||||
.user-chip.sortable-ghost { opacity: 0.5; }
|
.user-chip.sortable-ghost { opacity: 0.5; }
|
||||||
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||||
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
|
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
import SessionList from '@/components/SessionList.vue'
|
import SessionList from '@/components/SessionList.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -23,6 +24,8 @@ const authStore = useAuthStore()
|
|||||||
const terminatingSessions = ref({})
|
const terminatingSessions = ref({})
|
||||||
const hoveredCredentialUuid = ref(null)
|
const hoveredCredentialUuid = ref(null)
|
||||||
const hoveredSession = ref(null)
|
const hoveredSession = ref(null)
|
||||||
|
const showPictureDialog = ref(false)
|
||||||
|
const avatarRenderVersion = ref(0)
|
||||||
|
|
||||||
// Convert credentials dict to array with uuid attached as 'credential'
|
// Convert credentials dict to array with uuid attached as 'credential'
|
||||||
const credentials = computed(() =>
|
const credentials = computed(() =>
|
||||||
@@ -48,6 +51,20 @@ function handleEditName() {
|
|||||||
emit('editUserName', props.selectedUser)
|
emit('editUserName', props.selectedUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPictureDialog() {
|
||||||
|
if (!props.userDetail || props.userDetail.error) return
|
||||||
|
showPictureDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePictureDialog() {
|
||||||
|
showPictureDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePictureUpdated() {
|
||||||
|
avatarRenderVersion.value += 1
|
||||||
|
emit('refreshUserDetail')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(credential) {
|
async function handleDelete(credential) {
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||||
@@ -102,7 +119,7 @@ function handleUserInfoKeydown(event) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (direction === 'left' || direction === 'right') {
|
if (direction === 'left' || direction === 'right') {
|
||||||
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
|
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
} else if (direction === 'up') {
|
} else if (direction === 'up') {
|
||||||
emit('navigateOut', 'up')
|
emit('navigateOut', 'up')
|
||||||
} else if (direction === 'down') {
|
} else if (direction === 'down') {
|
||||||
@@ -124,7 +141,7 @@ function handleRegActionsKeydown(event) {
|
|||||||
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
||||||
} else if (direction === 'up') {
|
} else if (direction === 'up') {
|
||||||
// Move to user info edit button
|
// Move to user info edit button
|
||||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
} else if (direction === 'down') {
|
} else if (direction === 'down') {
|
||||||
// Move to credential list
|
// Move to credential list
|
||||||
credentialListRef.value?.$el?.focus()
|
credentialListRef.value?.$el?.focus()
|
||||||
@@ -174,10 +191,22 @@ function handleBackButtonKeydown(event) {
|
|||||||
|
|
||||||
// Focus helper for external navigation
|
// Focus helper for external navigation
|
||||||
function focusFirstElement() {
|
function focusFirstElement() {
|
||||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ focusFirstElement })
|
defineExpose({ focusFirstElement })
|
||||||
|
|
||||||
|
const currentPictureEndpoint = computed(() => {
|
||||||
|
if (!props.selectedUser?.uuid) return null
|
||||||
|
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminPictureTitle = computed(() => {
|
||||||
|
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
|
||||||
|
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
|
||||||
|
const label = username || displayName || 'User'
|
||||||
|
return `Profile Picture for ${label}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -186,6 +215,9 @@ defineExpose({ focusFirstElement })
|
|||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="userDetail && !userDetail.error"
|
v-if="userDetail && !userDetail.error"
|
||||||
:name="userDetail.user.display_name || selectedUser.display_name"
|
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||||
|
:avatar-url="userDetail.user.avatar_url"
|
||||||
|
:avatar-render-version="avatarRenderVersion"
|
||||||
|
avatar-clickable
|
||||||
:visits="userDetail.user.visits"
|
:visits="userDetail.user.visits"
|
||||||
:created-at="userDetail.user.created_at"
|
:created-at="userDetail.user.created_at"
|
||||||
:last-seen="userDetail.user.last_seen"
|
:last-seen="userDetail.user.last_seen"
|
||||||
@@ -196,6 +228,7 @@ defineExpose({ focusFirstElement })
|
|||||||
:role-name="userDetail.role.display_name"
|
:role-name="userDetail.role.display_name"
|
||||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||||
@saved="$emit('onUserNameSaved')"
|
@saved="$emit('onUserNameSaved')"
|
||||||
|
@avatar-click="openPictureDialog"
|
||||||
@edit="handleEditName"
|
@edit="handleEditName"
|
||||||
>
|
>
|
||||||
<div class="admin-actions">
|
<div class="admin-actions">
|
||||||
@@ -258,6 +291,15 @@ defineExpose({ focusFirstElement })
|
|||||||
@close="$emit('closeRegModal')"
|
@close="$emit('closeRegModal')"
|
||||||
@copied="onLinkCopied"
|
@copied="onLinkCopied"
|
||||||
/>
|
/>
|
||||||
|
<ProfilePictureEditorModal
|
||||||
|
v-if="showPictureDialog && currentPictureEndpoint"
|
||||||
|
:endpoint="currentPictureEndpoint"
|
||||||
|
:picture-url="userDetail?.user?.avatar_url"
|
||||||
|
:render-version="avatarRenderVersion"
|
||||||
|
:title="adminPictureTitle"
|
||||||
|
@close="closePictureDialog"
|
||||||
|
@updated="handlePictureUpdated"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340">
|
||||||
|
<path fill="#DDD" d="m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1
|
||||||
|
1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 220 B |
@@ -10,6 +10,7 @@
|
|||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="ctx"
|
v-if="ctx"
|
||||||
:name="ctx.user.display_name"
|
:name="ctx.user.display_name"
|
||||||
|
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||||
:visits="authStore.userInfo.user.visits"
|
:visits="authStore.userInfo.user.visits"
|
||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dialog-overlay" @click="$emit('close')">
|
<div class="dialog-overlay" @click="$emit('close')">
|
||||||
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,9 @@ const props = defineProps({
|
|||||||
// Optional: index to help find next sibling when item is deleted
|
// Optional: index to help find next sibling when item is deleted
|
||||||
focusIndex: { type: Number, default: -1 },
|
focusIndex: { type: Number, default: -1 },
|
||||||
// Optional: selector for finding siblings when restoring focus
|
// Optional: selector for finding siblings when restoring focus
|
||||||
focusSiblingSelector: { type: String, default: '' }
|
focusSiblingSelector: { type: String, default: '' },
|
||||||
|
// Optional: extra class name(s) for the modal panel
|
||||||
|
panelClass: { type: [String, Array, Object], default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="rootTag"
|
||||||
|
v-bind="rootAttrs"
|
||||||
|
class="profile-picture"
|
||||||
|
:class="{ 'profile-picture-btn': clickable }"
|
||||||
|
:style="pictureStyle"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="showPicture"
|
||||||
|
:key="`${src || 'none'}:${renderVersion}`"
|
||||||
|
:src="src"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-image"
|
||||||
|
@error="handleError"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="profileGeneric"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-fallback"
|
||||||
|
/>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import profileGeneric from '@/assets/profile-generic.svg'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: { type: String, default: null },
|
||||||
|
clickable: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
renderVersion: { type: [Number, String], default: 0 },
|
||||||
|
width: { type: String, default: '3rem' },
|
||||||
|
height: { type: String, default: '3rem' },
|
||||||
|
radius: { type: String, default: '0.9rem' },
|
||||||
|
fit: { type: String, default: 'cover' },
|
||||||
|
filter: { type: String, default: 'none' },
|
||||||
|
fallbackSize: { type: String, default: '2em' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
|
const pictureAvailable = ref(true)
|
||||||
|
|
||||||
|
const rootTag = computed(() => (props.clickable ? 'button' : 'div'))
|
||||||
|
const showPicture = computed(() => !!props.src && pictureAvailable.value)
|
||||||
|
const pictureStyle = computed(() => ({
|
||||||
|
'--profile-picture-width': props.width,
|
||||||
|
'--profile-picture-height': props.height,
|
||||||
|
'--profile-picture-radius': props.radius,
|
||||||
|
'--profile-picture-fit': props.fit,
|
||||||
|
'--profile-picture-filter': props.filter,
|
||||||
|
'--profile-picture-fallback-size': props.fallbackSize
|
||||||
|
}))
|
||||||
|
const rootAttrs = computed(() => {
|
||||||
|
if (!props.clickable) return { title: props.title || undefined }
|
||||||
|
return {
|
||||||
|
type: 'button',
|
||||||
|
disabled: props.loading,
|
||||||
|
title: props.title || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.src, () => {
|
||||||
|
pictureAvailable.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
pictureAvailable.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!props.clickable || props.loading) return
|
||||||
|
emit('click')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-picture {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--profile-picture-width);
|
||||||
|
height: var(--profile-picture-height);
|
||||||
|
font-size: var(--profile-picture-fallback-size);
|
||||||
|
line-height: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--profile-picture-radius);
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn:disabled {
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: var(--profile-picture-fit);
|
||||||
|
display: block;
|
||||||
|
filter: var(--profile-picture-filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<template>
|
||||||
|
<Modal panel-class="modal-panel--avatar" @close="closeEditor">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<input
|
||||||
|
ref="pictureInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="profile-picture-editor-input"
|
||||||
|
:disabled="saving"
|
||||||
|
@change="handlePictureSelected"
|
||||||
|
/>
|
||||||
|
<div ref="picturePreview" class="profile-picture-editor-preview" :style="previewStyle">
|
||||||
|
<img
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
:src="editorImageUrl"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-editor-image"
|
||||||
|
:style="editorImageStyle"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
:src="editorImageUrl"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-editor-image profile-picture-editor-image--overlay"
|
||||||
|
:style="editorOverlayStyle"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
class="profile-picture-editor-crop"
|
||||||
|
:style="cropBoxStyle"
|
||||||
|
@pointerdown="startMove"
|
||||||
|
>
|
||||||
|
<div class="profile-picture-editor-guides" aria-hidden="true">
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--circle"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--eyes"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-left"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-right"></div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--nw"
|
||||||
|
@pointerdown.stop="startResize($event, 'nw')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--ne"
|
||||||
|
@pointerdown.stop="startResize($event, 'ne')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--sw"
|
||||||
|
@pointerdown.stop="startResize($event, 'sw')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--se"
|
||||||
|
@pointerdown.stop="startResize($event, 'se')"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<ProfilePicture
|
||||||
|
v-else
|
||||||
|
class="profile-picture-editor-trigger"
|
||||||
|
:src="pictureUrl"
|
||||||
|
:render-version="renderVersion"
|
||||||
|
clickable
|
||||||
|
:loading="saving"
|
||||||
|
title="Choose profile picture"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
radius="0"
|
||||||
|
fit="contain"
|
||||||
|
fallback-size="5rem"
|
||||||
|
@click="triggerPictureSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage" class="error small">{{ errorMessage }}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" :disabled="saving" @click="closeEditor">Back</button>
|
||||||
|
<button
|
||||||
|
v-if="!editorImageUrl && pictureUrl"
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="removePicture"
|
||||||
|
>Delete</button>
|
||||||
|
<button
|
||||||
|
v-if="editorImageUrl"
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="savePicture"
|
||||||
|
>Save</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { apiJson } from 'paskia'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import Modal from '@/components/Modal.vue'
|
||||||
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
|
|
||||||
|
const AVATAR_UPLOAD_SIZE = 720
|
||||||
|
const MIN_CROP_SIZE = 36
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
endpoint: { type: String, required: true },
|
||||||
|
pictureUrl: { type: String, default: null },
|
||||||
|
renderVersion: { type: [Number, String], default: 0 },
|
||||||
|
title: { type: String, default: 'Profile Picture' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'updated'])
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const pictureInput = ref(null)
|
||||||
|
const picturePreview = ref(null)
|
||||||
|
const editorImage = ref(null)
|
||||||
|
const editorImageUrl = ref('')
|
||||||
|
const previewObjectUrl = ref(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const cropRect = reactive({ x: 0, y: 0, size: 0 })
|
||||||
|
const previewRect = reactive({ width: 0, height: 0 })
|
||||||
|
const viewportSize = reactive({ width: 0, height: 0 })
|
||||||
|
let dragState = null
|
||||||
|
let previewObserver = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
viewportSize.width = window.innerWidth
|
||||||
|
viewportSize.height = window.innerHeight
|
||||||
|
window.addEventListener('pointermove', handlePointerMove)
|
||||||
|
window.addEventListener('pointerup', endPointerInteraction)
|
||||||
|
window.addEventListener('resize', syncPreviewRect)
|
||||||
|
await nextTick()
|
||||||
|
syncPreviewRect()
|
||||||
|
if (picturePreview.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
previewObserver = new ResizeObserver(() => syncPreviewRect())
|
||||||
|
previewObserver.observe(picturePreview.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
window.removeEventListener('pointerup', endPointerInteraction)
|
||||||
|
window.removeEventListener('resize', syncPreviewRect)
|
||||||
|
previewObserver?.disconnect()
|
||||||
|
clearPreviewObjectUrl()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(editorImage, async (image) => {
|
||||||
|
if (!image) return
|
||||||
|
await nextTick()
|
||||||
|
syncPreviewRect()
|
||||||
|
initializeCrop()
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearPreviewObjectUrl = () => {
|
||||||
|
if (!previewObjectUrl.value) return
|
||||||
|
URL.revokeObjectURL(previewObjectUrl.value)
|
||||||
|
previewObjectUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetEditor = () => {
|
||||||
|
clearPreviewObjectUrl()
|
||||||
|
editorImage.value = null
|
||||||
|
editorImageUrl.value = ''
|
||||||
|
cropRect.x = 0
|
||||||
|
cropRect.y = 0
|
||||||
|
cropRect.size = 0
|
||||||
|
errorMessage.value = ''
|
||||||
|
if (pictureInput.value) pictureInput.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncPreviewRect = () => {
|
||||||
|
viewportSize.width = window.innerWidth
|
||||||
|
viewportSize.height = window.innerHeight
|
||||||
|
const element = picturePreview.value
|
||||||
|
if (!element) return
|
||||||
|
previewRect.width = element.clientWidth
|
||||||
|
previewRect.height = element.clientHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStyle = computed(() => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image) {
|
||||||
|
const size = Math.min(viewportSize.width * 0.72, viewportSize.height * 0.42, 352)
|
||||||
|
return {
|
||||||
|
width: `${Math.max(160, Math.round(size))}px`,
|
||||||
|
height: `${Math.max(160, Math.round(size))}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidth = Math.min(viewportSize.width * 0.88, 928)
|
||||||
|
const maxHeight = Math.min(viewportSize.height * 0.62, 620)
|
||||||
|
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight)
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${Math.max(1, Math.round(image.naturalWidth * scale))}px`,
|
||||||
|
height: `${Math.max(1, Math.round(image.naturalHeight * scale))}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayMetrics = computed(() => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image || !previewRect.width || !previewRect.height) return null
|
||||||
|
const scale = Math.min(previewRect.width / image.naturalWidth, previewRect.height / image.naturalHeight)
|
||||||
|
const width = image.naturalWidth * scale
|
||||||
|
const height = image.naturalHeight * scale
|
||||||
|
return {
|
||||||
|
x: (previewRect.width - width) / 2,
|
||||||
|
y: (previewRect.height - height) / 2,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorImageStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return null
|
||||||
|
return {
|
||||||
|
width: `${metrics.width}px`,
|
||||||
|
height: `${metrics.height}px`,
|
||||||
|
left: `${metrics.x}px`,
|
||||||
|
top: `${metrics.y}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorOverlayStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) return editorImageStyle.value
|
||||||
|
|
||||||
|
const left = cropRect.x
|
||||||
|
const top = cropRect.y
|
||||||
|
const right = cropRect.x + cropRect.size
|
||||||
|
const bottom = cropRect.y + cropRect.size
|
||||||
|
|
||||||
|
return {
|
||||||
|
...editorImageStyle.value,
|
||||||
|
clipPath: `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px)`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cropBoxStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) return null
|
||||||
|
return {
|
||||||
|
left: `${metrics.x + cropRect.x}px`,
|
||||||
|
top: `${metrics.y + cropRect.y}px`,
|
||||||
|
width: `${cropRect.size}px`,
|
||||||
|
height: `${cropRect.size}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const initializeCrop = () => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return
|
||||||
|
const size = Math.min(metrics.width, metrics.height)
|
||||||
|
cropRect.size = size
|
||||||
|
cropRect.x = (metrics.width - size) / 2
|
||||||
|
cropRect.y = (metrics.height - size) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerPictureSelect = () => {
|
||||||
|
pictureInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePictureSelected = async (event) => {
|
||||||
|
const nextFile = event.target.files?.[0] || null
|
||||||
|
resetEditor()
|
||||||
|
if (!nextFile) return
|
||||||
|
|
||||||
|
previewObjectUrl.value = URL.createObjectURL(nextFile)
|
||||||
|
editorImageUrl.value = previewObjectUrl.value
|
||||||
|
const image = new Image()
|
||||||
|
image.decoding = 'async'
|
||||||
|
image.src = editorImageUrl.value
|
||||||
|
try {
|
||||||
|
await image.decode()
|
||||||
|
editorImage.value = image
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Failed to load image'
|
||||||
|
resetEditor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMove = (event) => {
|
||||||
|
if (!displayMetrics.value || saving.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
dragState = {
|
||||||
|
mode: 'move',
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
initialX: cropRect.x,
|
||||||
|
initialY: cropRect.y,
|
||||||
|
initialSize: cropRect.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResize = (event, handle) => {
|
||||||
|
if (!displayMetrics.value || saving.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
dragState = {
|
||||||
|
mode: 'resize',
|
||||||
|
handle,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
initialX: cropRect.x,
|
||||||
|
initialY: cropRect.y,
|
||||||
|
initialSize: cropRect.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event) => {
|
||||||
|
if (!dragState) return
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return
|
||||||
|
|
||||||
|
const dx = event.clientX - dragState.startX
|
||||||
|
const dy = event.clientY - dragState.startY
|
||||||
|
|
||||||
|
if (dragState.mode === 'move') {
|
||||||
|
cropRect.x = Math.max(0, Math.min(metrics.width - dragState.initialSize, dragState.initialX + dx))
|
||||||
|
cropRect.y = Math.max(0, Math.min(metrics.height - dragState.initialSize, dragState.initialY + dy))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionMap = {
|
||||||
|
nw: { deltaX: -1, deltaY: -1 },
|
||||||
|
ne: { deltaX: 1, deltaY: -1 },
|
||||||
|
sw: { deltaX: -1, deltaY: 1 },
|
||||||
|
se: { deltaX: 1, deltaY: 1 }
|
||||||
|
}
|
||||||
|
const direction = directionMap[dragState.handle]
|
||||||
|
if (!direction) return
|
||||||
|
|
||||||
|
const delta = Math.max(dx * direction.deltaX, dy * direction.deltaY)
|
||||||
|
const nextSize = Math.max(
|
||||||
|
MIN_CROP_SIZE,
|
||||||
|
Math.min(getResizeLimit(metrics, dragState), dragState.initialSize + delta)
|
||||||
|
)
|
||||||
|
|
||||||
|
applyResize(dragState, nextSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
const endPointerInteraction = () => {
|
||||||
|
dragState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResizeLimit = (metrics, state) => {
|
||||||
|
const { initialX, initialY, initialSize, handle } = state
|
||||||
|
|
||||||
|
if (handle === 'nw') return Math.min(initialX + initialSize, initialY + initialSize)
|
||||||
|
if (handle === 'ne') return Math.min(metrics.width - initialX, initialY + initialSize)
|
||||||
|
if (handle === 'sw') return Math.min(initialX + initialSize, metrics.height - initialY)
|
||||||
|
return Math.min(metrics.width - initialX, metrics.height - initialY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyResize = (state, size) => {
|
||||||
|
const { initialX, initialY, initialSize, handle } = state
|
||||||
|
|
||||||
|
if (handle === 'nw') {
|
||||||
|
cropRect.x = initialX + initialSize - size
|
||||||
|
cropRect.y = initialY + initialSize - size
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle === 'ne') {
|
||||||
|
cropRect.x = initialX
|
||||||
|
cropRect.y = initialY + initialSize - size
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle === 'sw') {
|
||||||
|
cropRect.x = initialX + initialSize - size
|
||||||
|
cropRect.y = initialY
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cropRect.x = initialX
|
||||||
|
cropRect.y = initialY
|
||||||
|
cropRect.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPictureBlob = async () => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image) throw new Error('No image selected')
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) throw new Error('Crop selection unavailable')
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = AVATAR_UPLOAD_SIZE
|
||||||
|
canvas.height = AVATAR_UPLOAD_SIZE
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('Canvas unavailable')
|
||||||
|
|
||||||
|
const sourceScale = image.naturalWidth / metrics.width
|
||||||
|
const sourceX = cropRect.x * sourceScale
|
||||||
|
const sourceY = cropRect.y * sourceScale
|
||||||
|
const sourceSize = cropRect.size * sourceScale
|
||||||
|
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, AVATAR_UPLOAD_SIZE, AVATAR_UPLOAD_SIZE)
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('Failed to export cropped picture'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(blob)
|
||||||
|
}, 'image/webp', 0.9)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadPictureFromCache = async () => {
|
||||||
|
const response = await fetch(props.endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'reload'
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to refresh profile picture')
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePicture = async () => {
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
const blob = await renderPictureBlob()
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', blob, 'profile.webp')
|
||||||
|
await apiJson(props.endpoint, { method: 'PUT', body: formData })
|
||||||
|
await reloadPictureFromCache()
|
||||||
|
authStore.showMessage('Profile picture updated.', 'success', 3000)
|
||||||
|
emit('updated')
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || 'Failed to update profile picture'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePicture = async () => {
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
await apiJson(props.endpoint, { method: 'DELETE' })
|
||||||
|
authStore.showMessage('Profile picture removed.', 'success', 3000)
|
||||||
|
emit('updated')
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || 'Failed to remove profile picture'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
resetEditor()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-picture-editor-input { display: none; }
|
||||||
|
.profile-picture-editor-preview { position: relative; display: flex; justify-content: center; align-items: center; width: auto; max-width: min(58rem, 88vw); min-height: 0; margin: 0 auto; overflow: visible; }
|
||||||
|
.profile-picture-editor-trigger { min-width: 0; }
|
||||||
|
.profile-picture-editor-image { position: absolute; user-select: none; pointer-events: none; object-fit: contain; }
|
||||||
|
.profile-picture-editor-image--overlay { filter: grayscale(0.45) saturate(0.7) brightness(0.68); }
|
||||||
|
.profile-picture-editor-crop { position: absolute; border: 2px solid white; cursor: move; touch-action: none; }
|
||||||
|
.profile-picture-editor-guides { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.profile-picture-editor-guide { position: absolute; border-color: rgba(255, 255, 255, 0.52); }
|
||||||
|
.profile-picture-editor-guide--circle { inset: 0; border: 1.5px solid rgba(255, 255, 255, 0.62); border-radius: 999px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
|
||||||
|
.profile-picture-editor-guide--eyes { left: 18%; right: 18%; top: 38%; border-top: 1.5px solid rgba(255, 255, 255, 0.56); }
|
||||||
|
.profile-picture-editor-guide--cheek-left { top: 24%; bottom: 18%; left: 24%; border-left: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||||
|
.profile-picture-editor-guide--cheek-right { top: 24%; bottom: 18%; right: 24%; border-right: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||||
|
.profile-picture-editor-handle { position: absolute; width: 1.1rem; height: 1.1rem; border-radius: 999px; border: 2px solid white; background: var(--color-accent); padding: 0; }
|
||||||
|
.profile-picture-editor-handle--nw { left: -0.55rem; top: -0.55rem; cursor: nwse-resize; }
|
||||||
|
.profile-picture-editor-handle--ne { right: -0.55rem; top: -0.55rem; cursor: nesw-resize; }
|
||||||
|
.profile-picture-editor-handle--sw { left: -0.55rem; bottom: -0.55rem; cursor: nesw-resize; }
|
||||||
|
.profile-picture-editor-handle--se { right: -0.55rem; bottom: -0.55rem; cursor: nwse-resize; }
|
||||||
|
:deep(.modal-panel--avatar) { width: fit-content; max-width: min(58rem, 94vw); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.profile-picture-editor-preview { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
v-if="authStore.userInfo?.user"
|
v-if="authStore.userInfo?.user"
|
||||||
ref="userBasicInfo"
|
ref="userBasicInfo"
|
||||||
:name="authStore.userInfo.user.display_name"
|
:name="authStore.userInfo.user.display_name"
|
||||||
|
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||||
|
:avatar-render-version="avatarRenderVersion"
|
||||||
|
avatar-clickable
|
||||||
:email="authStore.userInfo.user.email"
|
:email="authStore.userInfo.user.email"
|
||||||
:preferred_username="authStore.userInfo.user.preferred_username"
|
:preferred_username="authStore.userInfo.user.preferred_username"
|
||||||
:telephone="authStore.userInfo.user.telephone"
|
:telephone="authStore.userInfo.user.telephone"
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
:role-name="authStore.userInfo.role.display_name"
|
:role-name="authStore.userInfo.role.display_name"
|
||||||
update-endpoint="/auth/api/user/info"
|
update-endpoint="/auth/api/user/info"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
|
@avatar-click="openAvatarDialog"
|
||||||
@edit="openEditDialog"
|
@edit="openEditDialog"
|
||||||
@keydown="handleUserInfoKeydown"
|
@keydown="handleUserInfoKeydown"
|
||||||
>
|
>
|
||||||
@@ -131,6 +135,15 @@
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ProfilePictureEditorModal
|
||||||
|
v-if="showAvatarDialog && currentAvatarEndpoint"
|
||||||
|
:endpoint="currentAvatarEndpoint"
|
||||||
|
:picture-url="authStore.userInfo?.user?.avatar_url"
|
||||||
|
:render-version="avatarRenderVersion"
|
||||||
|
@close="closeAvatarDialog"
|
||||||
|
@updated="handleProfilePictureUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegLink"
|
v-if="showRegLink"
|
||||||
endpoint="/auth/api/user/create-link"
|
endpoint="/auth/api/user/create-link"
|
||||||
@@ -144,6 +157,7 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
|
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||||
import ThemeSelector from '@/components/ThemeSelector.vue'
|
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
@@ -160,11 +174,13 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
const updateInterval = ref(null)
|
||||||
const showEditDialog = ref(false)
|
const showEditDialog = ref(false)
|
||||||
|
const showAvatarDialog = ref(false)
|
||||||
const showRegLink = ref(false)
|
const showRegLink = ref(false)
|
||||||
const editName = ref('')
|
const editName = ref('')
|
||||||
const editEmail = ref('')
|
const editEmail = ref('')
|
||||||
const editUsername = ref('')
|
const editUsername = ref('')
|
||||||
const editTelephone = ref('')
|
const editTelephone = ref('')
|
||||||
|
const avatarRenderVersion = ref(0)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editError = ref('')
|
const editError = ref('')
|
||||||
const hoveredCredentialUuid = ref(null)
|
const hoveredCredentialUuid = ref(null)
|
||||||
@@ -176,14 +192,15 @@ const credentialButtons = ref(null)
|
|||||||
const sessionList = ref(null)
|
const sessionList = ref(null)
|
||||||
const logoutButtons = ref(null)
|
const logoutButtons = ref(null)
|
||||||
const breadcrumbs = ref(null)
|
const breadcrumbs = ref(null)
|
||||||
const userBasicInfo = ref(null)
|
|
||||||
const userInfoSection = ref(null)
|
const userInfoSection = ref(null)
|
||||||
|
|
||||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||||
const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
|
const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
|
||||||
|
|
||||||
watch(showEditDialog, (open) => {
|
watch(showEditDialog, (open) => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const user = authStore.userInfo.user
|
const user = authStore.userInfo.user
|
||||||
editName.value = user.display_name ?? ''
|
editName.value = user.display_name ?? ''
|
||||||
editEmail.value = user.email ?? ''
|
editEmail.value = user.email ?? ''
|
||||||
@@ -196,7 +213,28 @@ onMounted(() => {
|
|||||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
onUnmounted(() => {
|
||||||
|
if (updateInterval.value) clearInterval(updateInterval.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentAvatarEndpoint = computed(() => {
|
||||||
|
const userUuid = authStore.userInfo?.user?.uuid
|
||||||
|
if (!userUuid) return null
|
||||||
|
return `/auth/api/user/${userUuid}/profile.webp`
|
||||||
|
})
|
||||||
|
|
||||||
|
const openAvatarDialog = () => {
|
||||||
|
showAvatarDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAvatarDialog = () => {
|
||||||
|
showAvatarDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProfilePictureUpdated = async () => {
|
||||||
|
await authStore.loadUserInfo()
|
||||||
|
avatarRenderVersion.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
const addNewCredential = async () => {
|
const addNewCredential = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -245,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
|
|||||||
if (direction === 'down') {
|
if (direction === 'down') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// Move to user info section - always focus edit button first
|
// Move to user info section - always focus edit button first
|
||||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||||
}
|
}
|
||||||
// ArrowUp at the top does nothing
|
// ArrowUp at the top does nothing
|
||||||
}
|
}
|
||||||
@@ -257,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
|
|||||||
if (!direction) return
|
if (!direction) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const itemSelector = '.mini-btn, .pairing-input'
|
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
|
||||||
|
|
||||||
if (direction === 'left' || direction === 'right') {
|
if (direction === 'left' || direction === 'right') {
|
||||||
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
||||||
@@ -278,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
|
|||||||
focusPreferredButton(credentialButtons.value)
|
focusPreferredButton(credentialButtons.value)
|
||||||
} else if (direction === 'up' || direction === 'left') {
|
} else if (direction === 'up' || direction === 'left') {
|
||||||
// Focus user info section - always focus edit button first
|
// Focus user info section - always focus edit button first
|
||||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +437,7 @@ const saveProfile = async () => {
|
|||||||
try {
|
try {
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
let changed = false
|
||||||
const body = {}
|
const body = {}
|
||||||
if (name !== user.display_name) body.display_name = name
|
if (name !== user.display_name) body.display_name = name
|
||||||
if (emailVal !== (user.email || null)) body.email = emailVal
|
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||||
@@ -406,6 +445,9 @@ const saveProfile = async () => {
|
|||||||
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||||
if (Object.keys(body).length) {
|
if (Object.keys(body).length) {
|
||||||
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
await authStore.loadUserInfo()
|
await authStore.loadUserInfo()
|
||||||
authStore.showMessage('Profile updated!', 'success', 3000)
|
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||||
<div class="user-info-content">
|
<div class="user-info-content">
|
||||||
<div class="user-picture">
|
<ProfilePicture
|
||||||
<span>👤</span>
|
:src="avatarUrl"
|
||||||
</div>
|
:render-version="avatarRenderVersion"
|
||||||
|
:clickable="avatarClickable"
|
||||||
|
:loading="loading"
|
||||||
|
:title="avatarClickable ? 'Change profile picture' : ''"
|
||||||
|
width="5.25rem"
|
||||||
|
height="5.25rem"
|
||||||
|
radius="var(--radius-sm)"
|
||||||
|
fallback-size="2.8em"
|
||||||
|
class="user-picture"
|
||||||
|
:class="avatarClickable ? 'user-picture-btn' : ''"
|
||||||
|
@click="emit('avatar-click')"
|
||||||
|
/>
|
||||||
<h3 class="user-name-heading">
|
<h3 class="user-name-heading">
|
||||||
<span class="user-name-row">
|
<span class="user-name-row">
|
||||||
<span class="display-name" :title="name">{{ name }}</span>
|
<span class="display-name" :title="name">{{ name }}</span>
|
||||||
@@ -42,11 +53,13 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
|
avatarUrl: { type: String, default: null },
|
||||||
|
avatarRenderVersion: { type: [Number, String], default: 0 },
|
||||||
email: { type: String, default: null },
|
email: { type: String, default: null },
|
||||||
preferred_username: { type: String, default: null },
|
preferred_username: { type: String, default: null },
|
||||||
telephone: { type: String, default: null },
|
telephone: { type: String, default: null },
|
||||||
@@ -55,14 +68,13 @@ const props = defineProps({
|
|||||||
lastSeen: { type: [String, Number, Date], default: null },
|
lastSeen: { type: [String, Number, Date], default: null },
|
||||||
updateEndpoint: { type: String, default: null },
|
updateEndpoint: { type: String, default: null },
|
||||||
canEdit: { type: Boolean, default: true },
|
canEdit: { type: Boolean, default: true },
|
||||||
|
avatarClickable: { type: Boolean, default: false },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
orgDisplayName: { type: String, default: '' },
|
orgDisplayName: { type: String, default: '' },
|
||||||
roleName: { type: String, default: '' }
|
roleName: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['saved', 'edit'])
|
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const userLoaded = computed(() => !!props.name)
|
const userLoaded = computed(() => !!props.name)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -96,12 +108,12 @@ const userLoaded = computed(() => !!props.name)
|
|||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"picture heading fields"
|
"picture heading fields"
|
||||||
"picture org fields"
|
"picture org fields"
|
||||||
". info info";
|
"picture info info";
|
||||||
gap: 0 1rem;
|
gap: 0 1rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
|
:deep(.user-picture) { grid-area: picture; align-self: stretch; }
|
||||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||||
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
|||||||
@@ -75,10 +75,16 @@ Discovery: `backchannel_logout_supported: true`
|
|||||||
- `GET /.well-known/openid-configuration` — Discovery
|
- `GET /.well-known/openid-configuration` — Discovery
|
||||||
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
||||||
- `POST /auth/oidc/token` — Exchange/refresh
|
- `POST /auth/oidc/token` — Exchange/refresh
|
||||||
- `GET /auth/oidc/userinfo` — User (bearer token)
|
- `GET /auth/oidc/userinfo` — User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
|
||||||
- `POST /auth/oidc/backchannel-logout` — Logout
|
- `POST /auth/oidc/backchannel-logout` — Logout
|
||||||
- `POST /auth/api/exchange` — Native auth code → cookie
|
- `POST /auth/api/exchange` — Native auth code → cookie
|
||||||
|
|
||||||
|
## Claims
|
||||||
|
|
||||||
|
- `profile` scope may include `name`, `preferred_username`, and `picture`
|
||||||
|
- `email` scope may include `email`
|
||||||
|
- `groups` is emitted from client-scoped permissions
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
||||||
|
|||||||
+3
-2
@@ -9,6 +9,7 @@ from fastapi_vue.hostutil import parse_endpoints
|
|||||||
|
|
||||||
from paskia._version import __version__
|
from paskia._version import __version__
|
||||||
from paskia.db.jsonl import load_readonly
|
from paskia.db.jsonl import load_readonly
|
||||||
|
from paskia.db.paths import db_file_path
|
||||||
from paskia.util import startupbox
|
from paskia.util import startupbox
|
||||||
from paskia.util.hostutil import (
|
from paskia.util.hostutil import (
|
||||||
normalize_auth_host_and_origins,
|
normalize_auth_host_and_origins,
|
||||||
@@ -75,9 +76,9 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Load stored config (read-only, no writes, no global state)
|
# Load stored config (read-only, no writes, no global state)
|
||||||
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
|
||||||
try:
|
try:
|
||||||
config = load_readonly(db_path, rp_id=args.rp_id).config
|
config = load_readonly(str(db_path), rp_id=args.rp_id).config
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
print(f"🛑 Paskia {__version__} could not load")
|
print(f"🛑 Paskia {__version__} could not load")
|
||||||
sys.exit(str(e))
|
sys.exit(str(e))
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ from paskia.util import hostutil
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_logger() -> None:
|
||||||
|
if logger.handlers:
|
||||||
|
return
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
_configure_logger()
|
||||||
|
|
||||||
# Shared log message template for admin reset links
|
# Shared log message template for admin reset links
|
||||||
ADMIN_RESET_MESSAGE = """
|
ADMIN_RESET_MESSAGE = """
|
||||||
👤 Admin %s
|
👤 Admin %s
|
||||||
|
|||||||
+29
-31
@@ -1,67 +1,61 @@
|
|||||||
"""
|
"""
|
||||||
Background task for database maintenance.
|
Background task for database maintenance.
|
||||||
|
|
||||||
Periodically flushes pending changes to disk and cleans up expired items.
|
Kanta handles periodic flushing to disk. This module keeps a small
|
||||||
|
companion task that periodically cleans up expired sessions/tokens.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
from kanta.exceptions import DatabaseError
|
||||||
|
|
||||||
import paskia.db.operations as _ops
|
import paskia.db.operations as _ops
|
||||||
from paskia.db.lifecycle import cleanup_expired
|
from paskia.db.lifecycle import cleanup_expired
|
||||||
|
|
||||||
FLUSH_INTERVAL = 0.1 # Flush to disk
|
|
||||||
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||||
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
_background_task: asyncio.Task | None = None
|
_background_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _sigterm_on_error(error: DatabaseError) -> None:
|
||||||
|
"""Exit the server when a database write fails."""
|
||||||
|
_logger.error("Fatal database error: %s", error)
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
async def flush() -> None:
|
async def flush() -> None:
|
||||||
"""Write all pending database changes to disk."""
|
"""Write all pending database changes to disk."""
|
||||||
store = _ops._db._store
|
store = _ops._store
|
||||||
if store is None:
|
if store is None:
|
||||||
_logger.warning("flush() called but _store is None")
|
_logger.warning("flush() called but _store is None")
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
await store.flush()
|
await store.flush()
|
||||||
|
except DatabaseError as e:
|
||||||
|
_sigterm_on_error(e)
|
||||||
|
|
||||||
|
|
||||||
async def _background_loop():
|
async def _background_loop():
|
||||||
"""Background task that periodically flushes changes and cleans up."""
|
"""Background task that periodically cleans up expired items."""
|
||||||
# Run cleanup immediately on startup to clear old expired items
|
# Run cleanup immediately on startup to clear old expired items
|
||||||
cleanup_expired()
|
cleanup_expired()
|
||||||
await flush()
|
|
||||||
|
|
||||||
last_cleanup = datetime.now(UTC)
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(FLUSH_INTERVAL)
|
await asyncio.sleep(CLEANUP_INTERVAL)
|
||||||
# Flush pending changes to disk
|
|
||||||
await flush()
|
|
||||||
|
|
||||||
# Run cleanup periodically
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
|
||||||
cleanup_expired()
|
cleanup_expired()
|
||||||
await flush() # Flush cleanup changes
|
|
||||||
last_cleanup = now
|
|
||||||
|
|
||||||
# Conditionally write a snapshot to speed up future startups
|
|
||||||
if _ops._db._store is not None:
|
|
||||||
_ops._db._store.maybe_snapshot()
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Final flush before exit
|
|
||||||
await flush()
|
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
_logger.debug("Error in database background loop", exc_info=True)
|
_logger.debug("Error in database background loop", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def start_background():
|
async def start_background():
|
||||||
"""Start the background flush/cleanup task."""
|
"""Start the background cleanup task."""
|
||||||
global _background_task
|
global _background_task
|
||||||
|
|
||||||
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
||||||
@@ -75,16 +69,15 @@ async def start_background():
|
|||||||
# Check if task is in current event loop
|
# Check if task is in current event loop
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
task_loop = _background_task.get_loop()
|
task_loop = _background_task.get_loop()
|
||||||
if loop is not task_loop:
|
if loop is task_loop:
|
||||||
_logger.debug("Background task in different event loop, restarting")
|
|
||||||
_background_task = None
|
|
||||||
else:
|
|
||||||
# Task is already running in same loop - idempotent, just return
|
# Task is already running in same loop - idempotent, just return
|
||||||
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
"Background task already running in same loop, skipping"
|
"Background task already running in same loop, skipping"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
_logger.debug("Background task in different event loop, restarting")
|
||||||
|
_background_task = None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("Error checking background task loop: %s, restarting", e)
|
_logger.debug("Error checking background task loop: %s, restarting", e)
|
||||||
_background_task = None
|
_background_task = None
|
||||||
@@ -94,7 +87,7 @@ async def start_background():
|
|||||||
|
|
||||||
|
|
||||||
async def stop_background():
|
async def stop_background():
|
||||||
"""Stop the background task, flush pending changes, and release the file lock."""
|
"""Stop the background cleanup task and close kanta."""
|
||||||
global _background_task
|
global _background_task
|
||||||
if _background_task:
|
if _background_task:
|
||||||
_background_task.cancel()
|
_background_task.cancel()
|
||||||
@@ -103,7 +96,12 @@ async def stop_background():
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
_background_task = None
|
_background_task = None
|
||||||
_ops._db._store.close()
|
store = _ops._store
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
await store.close()
|
||||||
|
except DatabaseError as e:
|
||||||
|
_sigterm_on_error(e)
|
||||||
|
|
||||||
|
|
||||||
# Aliases for backwards compatibility
|
# Aliases for backwards compatibility
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
"""Cross-platform locked file for the database (no separate .lock files).
|
|
||||||
|
|
||||||
Unix: open() + fcntl.flock (advisory, cooperative among processes that flock).
|
|
||||||
Windows: CreateFileW with FILE_SHARE_READ (OS-enforced, allows readers, blocks writers).
|
|
||||||
|
|
||||||
A single file descriptor is opened once for both reading and writing.
|
|
||||||
The lock is acquired atomically (on Windows) or immediately after open (on Unix),
|
|
||||||
and the same descriptor is used for the lifetime of the process: first to read
|
|
||||||
the existing content, then to append new writes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _fatal(msg: str) -> None:
|
|
||||||
"""Log a fatal error and exit immediately, bypassing exception handlers."""
|
|
||||||
_logger.critical(msg)
|
|
||||||
os._exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
|
||||||
import ctypes
|
|
||||||
from ctypes import wintypes
|
|
||||||
|
|
||||||
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
||||||
|
|
||||||
_GENERIC_READ = 0x80000000
|
|
||||||
_GENERIC_WRITE = 0x40000000
|
|
||||||
_FILE_SHARE_READ = 0x00000001
|
|
||||||
_OPEN_EXISTING = 3
|
|
||||||
_OPEN_ALWAYS = 4
|
|
||||||
_FILE_ATTRIBUTE_NORMAL = 0x80
|
|
||||||
_FILE_BEGIN = 0
|
|
||||||
_FILE_END = 2
|
|
||||||
_ERROR_SHARING_VIOLATION = 32
|
|
||||||
_INVALID_FILE_SIZE = 0xFFFFFFFF
|
|
||||||
|
|
||||||
_kernel32.CreateFileW.restype = wintypes.HANDLE
|
|
||||||
_kernel32.CreateFileW.argtypes = [
|
|
||||||
wintypes.LPCWSTR,
|
|
||||||
wintypes.DWORD,
|
|
||||||
wintypes.DWORD,
|
|
||||||
ctypes.c_void_p,
|
|
||||||
wintypes.DWORD,
|
|
||||||
wintypes.DWORD,
|
|
||||||
wintypes.HANDLE,
|
|
||||||
]
|
|
||||||
_kernel32.ReadFile.restype = wintypes.BOOL
|
|
||||||
_kernel32.ReadFile.argtypes = [
|
|
||||||
wintypes.HANDLE,
|
|
||||||
ctypes.c_void_p,
|
|
||||||
wintypes.DWORD,
|
|
||||||
ctypes.POINTER(wintypes.DWORD),
|
|
||||||
ctypes.c_void_p,
|
|
||||||
]
|
|
||||||
_kernel32.WriteFile.restype = wintypes.BOOL
|
|
||||||
_kernel32.WriteFile.argtypes = [
|
|
||||||
wintypes.HANDLE,
|
|
||||||
ctypes.c_void_p,
|
|
||||||
wintypes.DWORD,
|
|
||||||
ctypes.POINTER(wintypes.DWORD),
|
|
||||||
ctypes.c_void_p,
|
|
||||||
]
|
|
||||||
_kernel32.GetFileSize.restype = wintypes.DWORD
|
|
||||||
_kernel32.GetFileSize.argtypes = [
|
|
||||||
wintypes.HANDLE,
|
|
||||||
ctypes.POINTER(wintypes.DWORD),
|
|
||||||
]
|
|
||||||
_kernel32.SetFilePointer.restype = wintypes.DWORD
|
|
||||||
_kernel32.SetFilePointer.argtypes = [
|
|
||||||
wintypes.HANDLE,
|
|
||||||
wintypes.LONG,
|
|
||||||
ctypes.POINTER(wintypes.LONG),
|
|
||||||
wintypes.DWORD,
|
|
||||||
]
|
|
||||||
_kernel32.CloseHandle.restype = wintypes.BOOL
|
|
||||||
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
||||||
|
|
||||||
def _is_invalid_handle(handle) -> bool:
|
|
||||||
return ctypes.c_void_p(handle).value == ctypes.c_void_p(-1).value
|
|
||||||
|
|
||||||
else:
|
|
||||||
import fcntl
|
|
||||||
|
|
||||||
|
|
||||||
class LockedFile:
|
|
||||||
"""A file opened with an exclusive write lock.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
f = LockedFile()
|
|
||||||
f.open(path) # open + lock (read+write)
|
|
||||||
content = f.read() # read entire content
|
|
||||||
f.write(data) # append data (seeks to end first)
|
|
||||||
f.close() # release lock + close fd
|
|
||||||
|
|
||||||
Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected.
|
|
||||||
Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._fd: int | None = None # Unix fd or Windows HANDLE
|
|
||||||
|
|
||||||
def open(self, path: Path, *, create: bool = False) -> None:
|
|
||||||
"""Open *path* for read+write with an exclusive lock.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: File to open and lock.
|
|
||||||
create: If True, create the file if it doesn't exist (bootstrap).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SystemExit: If the file is locked by another process or not found.
|
|
||||||
"""
|
|
||||||
if self._fd is not None:
|
|
||||||
return # Already open (idempotent)
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
|
||||||
self._open_win32(path, create)
|
|
||||||
else:
|
|
||||||
self._open_unix(path, create)
|
|
||||||
|
|
||||||
def open_and_read(self, path: Path) -> bytes:
|
|
||||||
"""Open *path* with exclusive lock and read all content.
|
|
||||||
|
|
||||||
Combined operation for efficient use with asyncio.to_thread().
|
|
||||||
"""
|
|
||||||
self.open(path)
|
|
||||||
return self.read()
|
|
||||||
|
|
||||||
def read(self) -> bytes:
|
|
||||||
"""Read the entire file content from the beginning."""
|
|
||||||
if self._fd is None:
|
|
||||||
raise RuntimeError("LockedFile.read() called on a closed file")
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
|
||||||
return self._read_win32()
|
|
||||||
else:
|
|
||||||
return self._read_unix()
|
|
||||||
|
|
||||||
def write(self, data: bytes) -> None:
|
|
||||||
"""Append *data* to the end of the file."""
|
|
||||||
if self._fd is None:
|
|
||||||
raise RuntimeError("LockedFile.write() called on a closed file")
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
|
||||||
self._write_win32(data)
|
|
||||||
else:
|
|
||||||
self._write_unix(data)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Release the lock and close the file."""
|
|
||||||
if self._fd is None:
|
|
||||||
return
|
|
||||||
if sys.platform == "win32":
|
|
||||||
_kernel32.CloseHandle(self._fd)
|
|
||||||
else:
|
|
||||||
os.close(self._fd)
|
|
||||||
self._fd = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_open(self) -> bool:
|
|
||||||
return self._fd is not None
|
|
||||||
|
|
||||||
# -- Unix ----------------------------------------------------------------
|
|
||||||
|
|
||||||
def _open_unix(self, path: Path, create: bool) -> None:
|
|
||||||
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
|
||||||
try:
|
|
||||||
fd = os.open(path, flags, 0o666)
|
|
||||||
except FileNotFoundError:
|
|
||||||
_fatal(f"Database file not found: {path.resolve()}")
|
|
||||||
try:
|
|
||||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except OSError:
|
|
||||||
os.close(fd)
|
|
||||||
_fatal(f"🛑 {path.resolve()}: database already locked by another instance")
|
|
||||||
self._fd = fd
|
|
||||||
|
|
||||||
def _read_unix(self) -> bytes:
|
|
||||||
os.lseek(self._fd, 0, os.SEEK_SET)
|
|
||||||
chunks = []
|
|
||||||
while True:
|
|
||||||
chunk = os.read(self._fd, 1 << 20) # 1 MiB
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
chunks.append(chunk)
|
|
||||||
return b"".join(chunks)
|
|
||||||
|
|
||||||
def _write_unix(self, data: bytes) -> None:
|
|
||||||
os.lseek(self._fd, 0, os.SEEK_END)
|
|
||||||
os.write(self._fd, data)
|
|
||||||
|
|
||||||
# -- Windows -------------------------------------------------------------
|
|
||||||
|
|
||||||
def _open_win32(self, path: Path, create: bool) -> None:
|
|
||||||
disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING
|
|
||||||
handle = _kernel32.CreateFileW(
|
|
||||||
str(path),
|
|
||||||
_GENERIC_READ | _GENERIC_WRITE,
|
|
||||||
_FILE_SHARE_READ,
|
|
||||||
None,
|
|
||||||
disposition,
|
|
||||||
_FILE_ATTRIBUTE_NORMAL,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if _is_invalid_handle(handle):
|
|
||||||
err = ctypes.get_last_error()
|
|
||||||
if err == _ERROR_SHARING_VIOLATION:
|
|
||||||
_fatal(
|
|
||||||
f"🛑 {path.resolve()}: database already locked by another instance"
|
|
||||||
)
|
|
||||||
_fatal(f"Failed to open database {path.resolve()}: Windows error {err}")
|
|
||||||
self._fd = handle
|
|
||||||
|
|
||||||
def _read_win32(self) -> bytes:
|
|
||||||
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_BEGIN)
|
|
||||||
size = _kernel32.GetFileSize(self._fd, None)
|
|
||||||
if size == _INVALID_FILE_SIZE:
|
|
||||||
raise OSError(
|
|
||||||
f"GetFileSize failed: Windows error {ctypes.get_last_error()}"
|
|
||||||
)
|
|
||||||
if size == 0:
|
|
||||||
return b""
|
|
||||||
buf = ctypes.create_string_buffer(size)
|
|
||||||
bytes_read = wintypes.DWORD()
|
|
||||||
ok = _kernel32.ReadFile(self._fd, buf, size, ctypes.byref(bytes_read), None)
|
|
||||||
if not ok:
|
|
||||||
raise OSError(f"ReadFile failed: Windows error {ctypes.get_last_error()}")
|
|
||||||
return buf.raw[: bytes_read.value]
|
|
||||||
|
|
||||||
def _write_win32(self, data: bytes) -> None:
|
|
||||||
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_END)
|
|
||||||
written = wintypes.DWORD()
|
|
||||||
ok = _kernel32.WriteFile(
|
|
||||||
self._fd,
|
|
||||||
data,
|
|
||||||
len(data),
|
|
||||||
ctypes.byref(written),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not ok:
|
|
||||||
raise OSError(f"WriteFile failed: Windows error {ctypes.get_last_error()}")
|
|
||||||
+12
-312
@@ -1,82 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
JSONL persistence layer for the database.
|
JSONL read-only loader using kanta.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import copy
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
from collections import deque
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
import jsondiff
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
from kanta import replay as replay_jsonl
|
||||||
|
from kanta.migrate import MigrationRegistry
|
||||||
|
|
||||||
from paskia.db.filelock import LockedFile
|
from paskia.db.migrations import MigrationCtx
|
||||||
from paskia.db.logging import log_change
|
from paskia.db.structs import DB, Config
|
||||||
from paskia.db.migrations import (
|
|
||||||
DBVER,
|
|
||||||
MigrationCtx,
|
|
||||||
apply_all_migrations,
|
|
||||||
apply_migrations_readonly,
|
|
||||||
)
|
|
||||||
from paskia.db.snapshot import SnapshotState
|
|
||||||
from paskia.db.structs import DB, Config, SessionContext
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ReplayResult(msgspec.Struct, frozen=False):
|
|
||||||
"""Return value of _replay_from_data"""
|
|
||||||
|
|
||||||
state: dict = {}
|
|
||||||
v: int = 0
|
|
||||||
ts: datetime | None = None
|
|
||||||
snapts: datetime | None = None
|
|
||||||
changes: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseError(ValueError):
|
|
||||||
"""Exception raised for database loading errors."""
|
|
||||||
|
|
||||||
|
|
||||||
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
|
|
||||||
"""Replay database state from file data, using the last snapshot if available."""
|
|
||||||
resolved_path = str(Path(db_path).resolve())
|
|
||||||
result = ReplayResult()
|
|
||||||
|
|
||||||
# Find and apply the last snapshot
|
|
||||||
snap, start_offset = SnapshotState.load(data)
|
|
||||||
if snap:
|
|
||||||
result.state = snap.state
|
|
||||||
result.v = snap.v
|
|
||||||
result.snapts = snap.ts
|
|
||||||
|
|
||||||
# Replay change records after the snapshot
|
|
||||||
lines = data[start_offset:].split(b"\n")
|
|
||||||
for raw in lines:
|
|
||||||
line = raw.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
change = msgspec.json.decode(line, type=ChangeRecord)
|
|
||||||
except msgspec.DecodeError as e:
|
|
||||||
raise DatabaseError(
|
|
||||||
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
|
|
||||||
)
|
|
||||||
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
|
|
||||||
result.v = change.v
|
|
||||||
result.ts = change.ts
|
|
||||||
result.changes += 1
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
||||||
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
|
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
|
||||||
|
|
||||||
@@ -89,21 +27,21 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
content = path.read_bytes()
|
content = path.read_bytes()
|
||||||
r = _replay_from_data(content, str(path.resolve()))
|
rr = replay_jsonl(content)
|
||||||
data_dict = r.state
|
data_dict = rr.state
|
||||||
version = r.v
|
version = rr.version
|
||||||
|
|
||||||
if not data_dict:
|
if not data_dict:
|
||||||
return DB(config=Config(rp_id=rp_id))
|
return DB(config=Config(rp_id=rp_id))
|
||||||
|
|
||||||
# Apply migrations in-memory (no persistence)
|
# Apply migrations in-memory (no persistence)
|
||||||
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
registry = MigrationRegistry.from_module("paskia.db.migrations")
|
||||||
|
version = registry.apply(
|
||||||
|
data_dict, version, MigrationCtx(rp_id=rp_id), silent=True
|
||||||
|
)
|
||||||
|
|
||||||
# Decode to msgspec struct
|
# Decode to msgspec struct
|
||||||
try:
|
|
||||||
return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
||||||
except msgspec.ValidationError as e:
|
|
||||||
raise DatabaseError(f"{path.resolve()}: {e}") from None
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
_logger.exception("Failed to load database")
|
_logger.exception("Failed to load database")
|
||||||
raise SystemExit(f"{e}")
|
raise SystemExit(f"{e}")
|
||||||
@@ -112,241 +50,3 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception("Unexpected error loading database")
|
_logger.exception("Unexpected error loading database")
|
||||||
raise SystemExit(f"{e}")
|
raise SystemExit(f"{e}")
|
||||||
|
|
||||||
|
|
||||||
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
|
|
||||||
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
a: str = "" # action (e.g., "migrate", "login", "create_user")
|
|
||||||
v: int = 0 # schema version after this change
|
|
||||||
u: str | None = None # user UUID who performed the action (None for system)
|
|
||||||
diff: dict
|
|
||||||
|
|
||||||
|
|
||||||
def compute_diff(previous: dict, current: dict) -> dict | None:
|
|
||||||
return jsondiff.diff(previous, current, marshal=True) or None
|
|
||||||
|
|
||||||
|
|
||||||
# Actions that are allowed to create a new database file
|
|
||||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlStore:
|
|
||||||
"""JSONL persistence layer for a DB instance."""
|
|
||||||
|
|
||||||
def __init__(self, db: DB, db_path: str):
|
|
||||||
self.db: DB = db
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
self._file = LockedFile()
|
|
||||||
self._flush_failed = False
|
|
||||||
self._statedict: dict[str, Any] = {}
|
|
||||||
self._pending_changes: deque[ChangeRecord] = deque()
|
|
||||||
self._current_action: str = "system"
|
|
||||||
self._current_user: str | None = None
|
|
||||||
self._in_transaction: bool = False
|
|
||||||
self._transaction_snapshot: dict[str, Any] | None = None
|
|
||||||
self._v: int = DBVER # Schema version for new databases
|
|
||||||
self._snapshot = SnapshotState()
|
|
||||||
|
|
||||||
async def load(
|
|
||||||
self, db_path: str | None = None, *, rp_id: str = "localhost"
|
|
||||||
) -> None:
|
|
||||||
"""Load data from JSONL change log."""
|
|
||||||
if db_path is not None:
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
self._rp_id = rp_id
|
|
||||||
if not self.db_path.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Open with exclusive write lock and read contents — single threadpool call
|
|
||||||
content = await asyncio.to_thread(self._file.open_and_read, self.db_path)
|
|
||||||
|
|
||||||
# Replay change log to reconstruct state (snapshot-accelerated)
|
|
||||||
try:
|
|
||||||
r = _replay_from_data(content, str(self.db_path.resolve()))
|
|
||||||
statedict = r.state
|
|
||||||
self._v = r.v
|
|
||||||
self._snapshot.ts = r.snapts
|
|
||||||
self._snapshot.changes = r.changes
|
|
||||||
except (OSError, ValueError, msgspec.DecodeError, DatabaseError) as e:
|
|
||||||
raise SystemExit(f"{e}")
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("Unexpected error loading database")
|
|
||||||
raise SystemExit(f"{e}")
|
|
||||||
|
|
||||||
if not statedict:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Set previous state for diffing (will be updated by _queue_change)
|
|
||||||
self._statedict = copy.deepcopy(statedict)
|
|
||||||
|
|
||||||
# Callback to persist each migration
|
|
||||||
async def persist_migration(
|
|
||||||
action: str, new_version: int, current: dict
|
|
||||||
) -> None:
|
|
||||||
self._v = new_version
|
|
||||||
self._queue_change(action, new_version, current)
|
|
||||||
|
|
||||||
# Apply schema migrations one at a time
|
|
||||||
await apply_all_migrations(
|
|
||||||
statedict,
|
|
||||||
self._v,
|
|
||||||
persist_migration,
|
|
||||||
MigrationCtx(rp_id=rp_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Decode to msgspec struct
|
|
||||||
decoder = msgspec.json.Decoder(DB)
|
|
||||||
self.db = decoder.decode(msgspec.json.encode(statedict))
|
|
||||||
self.db._store = self
|
|
||||||
|
|
||||||
# Normalize via msgspec round-trip (handles omit_defaults etc.)
|
|
||||||
# This ensures _previous_builtins matches what msgspec would produce
|
|
||||||
normalized_dict = msgspec.to_builtins(self.db)
|
|
||||||
await persist_migration("migrate:msgspec", self._v, normalized_dict)
|
|
||||||
|
|
||||||
def _queue_change(
|
|
||||||
self, action: str, version: int, current: dict, user: str | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Queue a change record and log it.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: The action name for the change record
|
|
||||||
version: The schema version for the change record
|
|
||||||
current: The current state as a plain dict
|
|
||||||
user: Optional user UUID who performed the action
|
|
||||||
"""
|
|
||||||
diff = compute_diff(self._statedict, current)
|
|
||||||
if not diff:
|
|
||||||
return
|
|
||||||
self._pending_changes.append(
|
|
||||||
ChangeRecord(
|
|
||||||
a=action,
|
|
||||||
v=version,
|
|
||||||
u=user,
|
|
||||||
diff=diff,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log the change with user display name if available
|
|
||||||
user_display = None
|
|
||||||
if user:
|
|
||||||
try:
|
|
||||||
user_uuid = UUID(user)
|
|
||||||
if user_uuid in self.db.users:
|
|
||||||
user_display = self.db.users[user_uuid].display_name
|
|
||||||
except (ValueError, KeyError):
|
|
||||||
user_display = user
|
|
||||||
|
|
||||||
log_change(action, diff, user_display, self._statedict, self.db)
|
|
||||||
self._statedict = copy.deepcopy(current)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def transaction(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
ctx: SessionContext | None = None,
|
|
||||||
*,
|
|
||||||
user: str | None = None,
|
|
||||||
):
|
|
||||||
"""Wrap writes in transaction. Queues change on successful exit.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: Describes the operation (e.g., "Created user", "Login")
|
|
||||||
ctx: Session context of user performing the action (None for system operations)
|
|
||||||
user: User UUID string (alternative to ctx when full context unavailable)
|
|
||||||
"""
|
|
||||||
if self._in_transaction:
|
|
||||||
raise RuntimeError("Nested transactions are not supported")
|
|
||||||
|
|
||||||
# Check for out-of-transaction modifications
|
|
||||||
current_state = msgspec.to_builtins(self.db)
|
|
||||||
if current_state != self._statedict:
|
|
||||||
# Allow bootstrap to create a new database from empty state
|
|
||||||
is_bootstrap = action in _BOOTSTRAP_ACTIONS
|
|
||||||
if is_bootstrap and not self._statedict:
|
|
||||||
pass # Expected: creating database from scratch
|
|
||||||
else:
|
|
||||||
diff = compute_diff(self._statedict, current_state)
|
|
||||||
diff_json = msgspec.json.encode(diff).decode()
|
|
||||||
_logger.critical(
|
|
||||||
"Database state modified outside of transaction! "
|
|
||||||
"This indicates a bug where DB changes occurred without a transaction wrapper.\n"
|
|
||||||
f"Changes detected:\n{diff_json}"
|
|
||||||
)
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
old_action = self._current_action
|
|
||||||
old_user = self._current_user
|
|
||||||
self._current_action = action
|
|
||||||
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
|
|
||||||
self._current_user = str(ctx.user.uuid) if ctx else user
|
|
||||||
self._in_transaction = True
|
|
||||||
self._transaction_snapshot = current_state
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
current = msgspec.to_builtins(self.db)
|
|
||||||
self._queue_change(
|
|
||||||
self._current_action, self._v, current, self._current_user
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Rollback on error: restore from snapshot
|
|
||||||
_logger.warning("Transaction '%s' failed, rolling back changes", action)
|
|
||||||
if self._transaction_snapshot is not None:
|
|
||||||
decoder = msgspec.json.Decoder(DB)
|
|
||||||
self.db = decoder.decode(
|
|
||||||
msgspec.json.encode(self._transaction_snapshot)
|
|
||||||
)
|
|
||||||
self.db._store = self
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
self._current_action = old_action
|
|
||||||
self._current_user = old_user
|
|
||||||
self._in_transaction = False
|
|
||||||
self._transaction_snapshot = None
|
|
||||||
|
|
||||||
async def flush(self) -> None:
|
|
||||||
"""Write all pending changes to disk.
|
|
||||||
|
|
||||||
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
|
|
||||||
"""
|
|
||||||
if self._flush_failed or not self._pending_changes:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._file.is_open:
|
|
||||||
first_action = self._pending_changes[0].a
|
|
||||||
if first_action not in _BOOTSTRAP_ACTIONS:
|
|
||||||
_logger.error(
|
|
||||||
"Refusing to create database file with action '%s' - "
|
|
||||||
"only bootstrap can create a new database",
|
|
||||||
first_action,
|
|
||||||
)
|
|
||||||
self._flush_failed = True
|
|
||||||
os.kill(os.getpid(), signal.SIGTERM)
|
|
||||||
return
|
|
||||||
# Bootstrap: create and open the file with lock
|
|
||||||
await asyncio.to_thread(self._file.open, self.db_path, create=True)
|
|
||||||
|
|
||||||
changes_to_write = list(self._pending_changes)
|
|
||||||
|
|
||||||
try:
|
|
||||||
lines = [msgspec.json.encode(change) for change in changes_to_write]
|
|
||||||
if not lines:
|
|
||||||
self._pending_changes.clear()
|
|
||||||
return
|
|
||||||
|
|
||||||
await asyncio.to_thread(self._file.write, b"\n".join(lines) + b"\n")
|
|
||||||
self._snapshot.record_lines(len(lines))
|
|
||||||
self._pending_changes.clear()
|
|
||||||
except OSError as e:
|
|
||||||
_logger.error("Failed to flush database: %s", e)
|
|
||||||
self._flush_failed = True
|
|
||||||
os.kill(os.getpid(), signal.SIGTERM)
|
|
||||||
|
|
||||||
def maybe_snapshot(self) -> None:
|
|
||||||
"""Write a snapshot if conditions are met."""
|
|
||||||
self._snapshot.maybe_write(self._file, self._v, self._statedict)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Release the file lock and close the file."""
|
|
||||||
self._file.close()
|
|
||||||
|
|||||||
+32
-9
@@ -4,28 +4,51 @@ Database lifecycle: initialization and maintenance.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from kanta import Kanta
|
||||||
|
from kanta.exceptions import DatabaseError
|
||||||
|
|
||||||
import paskia.db.operations as _ops
|
import paskia.db.operations as _ops
|
||||||
from paskia import oidc_notify
|
from paskia import oidc_notify
|
||||||
from paskia.authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
from paskia.db.jsonl import JsonlStore
|
from paskia.db.migrations import MigrationCtx
|
||||||
|
from paskia.db.paths import db_file_path
|
||||||
|
from paskia.db.structs import DB
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _fatal_error(error: DatabaseError) -> None:
|
||||||
|
"""Fatal error callback: terminate the process on background write failures."""
|
||||||
|
_logger.error("Fatal database error: %s", error)
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
async def init(rp_id: str, *args, **kwargs):
|
async def init(rp_id: str, *args, **kwargs):
|
||||||
"""Load database from JSONL file."""
|
"""Load database from JSONL file using kanta."""
|
||||||
if _ops._db._store:
|
if _ops._store is not None:
|
||||||
_logger.debug("Database already initialized, skipping reload")
|
_logger.debug("Database already initialized, skipping reload")
|
||||||
return
|
return
|
||||||
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
|
db_path = db_file_path(rp_id=rp_id, create_root=True)
|
||||||
store = JsonlStore(_ops._db, db_path)
|
db = DB()
|
||||||
await store.load(db_path, rp_id=rp_id)
|
kanta = Kanta(
|
||||||
_ops._db = store.db
|
str(db_path),
|
||||||
_ops._db._store = store
|
db,
|
||||||
|
migrations="paskia.db.migrations",
|
||||||
|
migration_ctx=MigrationCtx(rp_id=rp_id),
|
||||||
|
fatal_error=_fatal_error,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await kanta.open()
|
||||||
|
except DatabaseError as e:
|
||||||
|
raise SystemExit(f"{e}") from e
|
||||||
|
_ops._store = kanta
|
||||||
|
_ops._db = db
|
||||||
|
_ops._db._store = kanta
|
||||||
# Request a snapshot after successful startup
|
# Request a snapshot after successful startup
|
||||||
store._snapshot.request_force()
|
kanta.request_snapshot()
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired() -> int:
|
def cleanup_expired() -> int:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import sys
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from kanta.logging import configure_logging as configure_kanta_logging
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from paskia.db.structs import DB
|
from paskia.db.structs import DB
|
||||||
|
|
||||||
@@ -464,3 +466,5 @@ def configure_db_logging() -> None:
|
|||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
|
# Kanta logs changes through its own logger; wire it to the same output.
|
||||||
|
configure_kanta_logging()
|
||||||
|
|||||||
+7
-41
@@ -6,17 +6,15 @@ Each migration should be idempotent and only run when needed.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
import msgspec
|
|
||||||
|
|
||||||
from paskia.util.crypto import secret_key
|
from paskia.util.crypto import secret_key
|
||||||
|
|
||||||
|
|
||||||
class MigrationCtx(msgspec.Struct):
|
class MigrationCtx:
|
||||||
"""Context passed to each migration function."""
|
"""Context passed to each migration function."""
|
||||||
|
|
||||||
rp_id: str
|
def __init__(self, rp_id: str):
|
||||||
|
self.rp_id = rp_id
|
||||||
|
|
||||||
|
|
||||||
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
|
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
|
||||||
@@ -42,7 +40,10 @@ def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
|
|||||||
# Session keys changed to hashes, drop old sessions
|
# Session keys changed to hashes, drop old sessions
|
||||||
d["sessions"] = {}
|
d["sessions"] = {}
|
||||||
# Create OIDC structure with a generated new key
|
# Create OIDC structure with a generated new key
|
||||||
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
|
d["oidc"] = {
|
||||||
|
"clients": {},
|
||||||
|
"key": base64.standard_b64encode(secret_key()).decode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
|
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
|
||||||
@@ -50,38 +51,3 @@ def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
|
|||||||
listen = d["config"].get("listen")
|
listen = d["config"].get("listen")
|
||||||
if listen and isinstance(listen, str):
|
if listen and isinstance(listen, str):
|
||||||
d["config"]["listen"] = [listen]
|
d["config"]["listen"] = [listen]
|
||||||
|
|
||||||
|
|
||||||
migrations = sorted(
|
|
||||||
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
|
||||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
|
||||||
)
|
|
||||||
|
|
||||||
DBVER = len(migrations) # Used by bootstrap to set initial version
|
|
||||||
|
|
||||||
|
|
||||||
def apply_migrations_readonly(
|
|
||||||
data_dict: dict,
|
|
||||||
current_version: int,
|
|
||||||
ctx: MigrationCtx,
|
|
||||||
) -> int:
|
|
||||||
"""Apply migration functions in-place without persistence.
|
|
||||||
|
|
||||||
Returns the new version after all migrations.
|
|
||||||
"""
|
|
||||||
while current_version < DBVER:
|
|
||||||
migrations[current_version](data_dict, ctx)
|
|
||||||
current_version += 1
|
|
||||||
return current_version
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_all_migrations(
|
|
||||||
data_dict: dict,
|
|
||||||
current_version: int,
|
|
||||||
persist: Callable[[str, int, dict], Awaitable[None]],
|
|
||||||
ctx: MigrationCtx,
|
|
||||||
) -> None:
|
|
||||||
while current_version < DBVER:
|
|
||||||
migrations[current_version](data_dict, ctx)
|
|
||||||
current_version += 1
|
|
||||||
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import uuid7
|
import uuid7
|
||||||
|
from kanta import Kanta
|
||||||
|
|
||||||
from paskia import oidc_notify
|
from paskia import oidc_notify
|
||||||
from paskia.config import SESSION_LIFETIME
|
from paskia.config import SESSION_LIFETIME
|
||||||
@@ -38,6 +39,7 @@ _UNSET = object()
|
|||||||
|
|
||||||
# Global database instance (empty until init() loads data)
|
# Global database instance (empty until init() loads data)
|
||||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||||
|
_store: Kanta[DB] | None = None
|
||||||
|
|
||||||
|
|
||||||
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||||
@@ -460,9 +462,7 @@ def update_session(
|
|||||||
s.validated = validated
|
s.validated = validated
|
||||||
|
|
||||||
|
|
||||||
def set_session_host(
|
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
|
||||||
key: str, host: str, *, ctx: SessionContext | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Set the host for a session (first-time binding)."""
|
"""Set the host for a session (first-time binding)."""
|
||||||
update_session(key, host=host, ctx=ctx)
|
update_session(key, host=host, ctx=ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def db_root_path(*, rp_id: str = "localhost") -> Path:
|
||||||
|
"""Return the configured persistence root directory."""
|
||||||
|
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
|
||||||
|
|
||||||
|
|
||||||
|
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||||
|
"""Return the JSONL database file path under the persistence root."""
|
||||||
|
root = db_root_path(rp_id=rp_id)
|
||||||
|
|
||||||
|
if root.is_file():
|
||||||
|
_migrate_legacy_db_file(root)
|
||||||
|
|
||||||
|
if create_root:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return root / "main.db"
|
||||||
|
|
||||||
|
|
||||||
|
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||||
|
"""Return the filesystem root for persisted user files."""
|
||||||
|
root = db_root_path(rp_id=rp_id)
|
||||||
|
|
||||||
|
if root.is_file():
|
||||||
|
_migrate_legacy_db_file(root)
|
||||||
|
|
||||||
|
if create_root:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return root / "users"
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy_db_file(legacy_path: Path) -> None:
|
||||||
|
"""Upgrade a legacy single-file database path into a directory root."""
|
||||||
|
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
|
||||||
|
shutil.rmtree(temp_root, ignore_errors=True)
|
||||||
|
temp_root.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
temp_root.mkdir(parents=True)
|
||||||
|
legacy_path.replace(temp_root / "main.db")
|
||||||
|
temp_root.rename(legacy_path)
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""
|
|
||||||
Snapshot handling for JSONL database persistence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import msgspec
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
LINEPREFIX = b"SNAPSHOT "
|
|
||||||
MINDIFFS = 100
|
|
||||||
|
|
||||||
|
|
||||||
class Snapshot(msgspec.Struct):
|
|
||||||
"""Snapshot data structure for database persistence."""
|
|
||||||
|
|
||||||
ts: datetime
|
|
||||||
v: int
|
|
||||||
state: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
class SnapshotState:
|
|
||||||
"""Tracks snapshot timing and line counts for a database file."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.ts: datetime | None = None
|
|
||||||
self.changes: int = 0
|
|
||||||
self._force_pending: bool = False
|
|
||||||
|
|
||||||
def request_force(self) -> None:
|
|
||||||
"""Request a forced snapshot on the next maybe_write call."""
|
|
||||||
self._force_pending = True
|
|
||||||
|
|
||||||
def record_lines(self, count: int) -> None:
|
|
||||||
self.changes += count
|
|
||||||
|
|
||||||
def maybe_write(self, file, version: int, state: dict) -> None:
|
|
||||||
"""Write a snapshot if conditions are met (enough changes, and Sunday UTC or forced)."""
|
|
||||||
if self.changes < MINDIFFS:
|
|
||||||
return
|
|
||||||
force = self._force_pending
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
if not force and now.weekday() != 6: # 6 = Sunday
|
|
||||||
return
|
|
||||||
sunday_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
if not force and self.ts is not None and self.ts >= sunday_midnight:
|
|
||||||
return
|
|
||||||
if not file.is_open:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._write(file, version, state, now)
|
|
||||||
self._force_pending = False
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.error("snapshot: failed to write snapshot: %r", exc)
|
|
||||||
|
|
||||||
def _write(self, file, version: int, state: dict, now: datetime) -> None:
|
|
||||||
"""Write a snapshot and update internal state."""
|
|
||||||
data = msgspec.json.encode(Snapshot(ts=now, v=version, state=state))
|
|
||||||
file.write(LINEPREFIX + data + b"\n")
|
|
||||||
self.changes = 0
|
|
||||||
self.ts = now
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def load(data: bytes) -> tuple[Snapshot | None, int]:
|
|
||||||
"""Find and parse the last snapshot in file data.
|
|
||||||
|
|
||||||
Returns (snapshot, replay_offset) where replay_offset is the byte
|
|
||||||
position to start replaying change records from. If no valid snapshot
|
|
||||||
is found, returns (None, 0).
|
|
||||||
"""
|
|
||||||
marker = b"\n" + LINEPREFIX
|
|
||||||
pos = data.rfind(marker)
|
|
||||||
if pos != -1:
|
|
||||||
pos += 1 # skip the newline
|
|
||||||
elif data.startswith(LINEPREFIX):
|
|
||||||
pos = 0
|
|
||||||
else:
|
|
||||||
return None, 0
|
|
||||||
|
|
||||||
end = data.find(b"\n", pos)
|
|
||||||
if end == -1:
|
|
||||||
raise ValueError("Incomplete snapshot line at end of file")
|
|
||||||
|
|
||||||
snap = msgspec.json.decode(data[pos + len(LINEPREFIX) : end], type=Snapshot)
|
|
||||||
return snap, end + 1
|
|
||||||
+17
-3
@@ -9,6 +9,7 @@ import msgspec
|
|||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
|
from paskia.db.logging import UuidResolver
|
||||||
from paskia.util import passphrase as passphrase_util
|
from paskia.util import passphrase as passphrase_util
|
||||||
from paskia.util.crypto import hash_secret
|
from paskia.util.crypto import hash_secret
|
||||||
|
|
||||||
@@ -618,7 +619,7 @@ class Config(msgspec.Struct, omit_defaults=True):
|
|||||||
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||||
"""In-memory database. Access fields directly for reads."""
|
"""In-memory database. Access fields directly for reads."""
|
||||||
|
|
||||||
config: Config
|
config: Config = msgspec.field(default_factory=lambda: Config(rp_id="localhost"))
|
||||||
permissions: dict[UUID, Permission] = {}
|
permissions: dict[UUID, Permission] = {}
|
||||||
orgs: dict[UUID, Org] = {}
|
orgs: dict[UUID, Org] = {}
|
||||||
roles: dict[UUID, Role] = {}
|
roles: dict[UUID, Role] = {}
|
||||||
@@ -652,8 +653,21 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
client.uuid = uuid
|
client.uuid = uuid
|
||||||
|
|
||||||
def transaction(self, action, ctx=None, *, user=None):
|
def transaction(self, action, ctx=None, *, user=None):
|
||||||
"""Wrap writes in transaction. Delegates to JsonlStore."""
|
"""Wrap writes in transaction. Delegates to Kanta."""
|
||||||
return self._store.transaction(action, ctx, user=user)
|
user_id = str(ctx.user.uuid) if ctx else user
|
||||||
|
user_display = None
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
user_uuid = UUID(user_id)
|
||||||
|
if user_uuid in self.users:
|
||||||
|
user_display = self.users[user_uuid].display_name
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
user_display = user_id
|
||||||
|
previous_state = msgspec.to_builtins(self)
|
||||||
|
resolver = UuidResolver(self, previous_state).resolve
|
||||||
|
return self._store.transaction(
|
||||||
|
action, user=user_id, user_display=user_display, resolver=resolver
|
||||||
|
)
|
||||||
|
|
||||||
def session_ctx(
|
def session_ctx(
|
||||||
self, session_secret: str, host: str | None = None
|
self, session_secret: str, host: str | None = None
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from paskia.fastapi.front import frontend
|
|||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import (
|
from paskia.util import (
|
||||||
|
avatar,
|
||||||
permutil,
|
permutil,
|
||||||
vitedev,
|
vitedev,
|
||||||
)
|
)
|
||||||
@@ -26,6 +27,7 @@ from paskia.util.apistructs import (
|
|||||||
ApiOrg,
|
ApiOrg,
|
||||||
ApiOrgResponse,
|
ApiOrgResponse,
|
||||||
ApiPermission,
|
ApiPermission,
|
||||||
|
ApiUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
@@ -79,7 +81,11 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
|
|||||||
org=ApiOrg.from_db(o),
|
org=ApiOrg.from_db(o),
|
||||||
permissions={p.uuid: p for p in o.permissions},
|
permissions={p.uuid: p for p in o.permissions},
|
||||||
roles={r.uuid: r for r in roles},
|
roles={r.uuid: r for r in roles},
|
||||||
users={u.uuid: u for r in roles for u in r.users},
|
users={
|
||||||
|
u.uuid: ApiUser.from_db(u, avatar_url=avatar.avatar_browser_url(u.uuid))
|
||||||
|
for r in roles
|
||||||
|
for u in r.users
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
|
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from paskia.fastapi import authz
|
|||||||
from paskia.fastapi.admin.errors import install_error_handlers
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil, permutil
|
from paskia.util import avatar, hostutil, permutil
|
||||||
from paskia.util.apistructs import (
|
from paskia.util.apistructs import (
|
||||||
ApiAaguidInfo,
|
ApiAaguidInfo,
|
||||||
ApiCreateLinkResponse,
|
ApiCreateLinkResponse,
|
||||||
@@ -165,7 +165,7 @@ async def admin_get_user_detail(
|
|||||||
|
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
ApiUserDetail(
|
ApiUserDetail(
|
||||||
user=ApiUser.from_db(user),
|
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||||
credentials={c.uuid: c for c in user.credentials},
|
credentials={c.uuid: c for c in user.credentials},
|
||||||
aaguid_info={
|
aaguid_info={
|
||||||
k: ApiAaguidInfo(**v)
|
k: ApiAaguidInfo(**v)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from paskia.fastapi import authz, session, user
|
|||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||||
from paskia.globals import passkey as global_passkey
|
from paskia.globals import passkey as global_passkey
|
||||||
from paskia.util.crypto import hash_secret
|
|
||||||
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
|
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
|
||||||
from paskia.util.apistructs import (
|
from paskia.util.apistructs import (
|
||||||
ApiCheckUserResponse,
|
ApiCheckUserResponse,
|
||||||
@@ -33,6 +32,7 @@ from paskia.util.apistructs import (
|
|||||||
ApiUserContext,
|
ApiUserContext,
|
||||||
ApiValidateResponse,
|
ApiValidateResponse,
|
||||||
)
|
)
|
||||||
|
from paskia.util.crypto import hash_secret
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=False)
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ def format_access_log(
|
|||||||
|
|
||||||
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
||||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
return (
|
||||||
|
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# WebSocket connection counter (mod 100)
|
# WebSocket connection counter (mod 100)
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ async def openid_configuration(request: Request):
|
|||||||
"name",
|
"name",
|
||||||
"preferred_username",
|
"preferred_username",
|
||||||
"email",
|
"email",
|
||||||
|
"picture",
|
||||||
"groups",
|
"groups",
|
||||||
"sid",
|
"sid",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
|
|||||||
|
|
||||||
from paskia import authcode, db
|
from paskia import authcode, db
|
||||||
from paskia.db.structs import Session
|
from paskia.db.structs import Session
|
||||||
from paskia.util import oidjwt
|
from paskia.util import avatar, oidjwt
|
||||||
from paskia.util.crypto import hash_secret
|
from paskia.util.crypto import hash_secret
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -361,6 +361,7 @@ def _build_token_response(
|
|||||||
name=user.display_name,
|
name=user.display_name,
|
||||||
preferred_username=user.preferred_username,
|
preferred_username=user.preferred_username,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
|
picture=avatar.current_avatar_url(user.uuid),
|
||||||
groups=groups or None,
|
groups=groups or None,
|
||||||
auth_time=auth_time,
|
auth_time=auth_time,
|
||||||
)
|
)
|
||||||
@@ -442,12 +443,15 @@ async def userinfo(
|
|||||||
|
|
||||||
# Build userinfo response based on scope
|
# Build userinfo response based on scope
|
||||||
scope = payload.get("scope", "openid").split()
|
scope = payload.get("scope", "openid").split()
|
||||||
response = {"sub": str(user.uuid)}
|
response: dict[str, object] = {"sub": str(user.uuid)}
|
||||||
|
|
||||||
if "profile" in scope:
|
if "profile" in scope:
|
||||||
response["name"] = user.display_name
|
response["name"] = user.display_name
|
||||||
if user.preferred_username:
|
if user.preferred_username:
|
||||||
response["preferred_username"] = user.preferred_username
|
response["preferred_username"] = user.preferred_username
|
||||||
|
picture = avatar.current_avatar_url(user.uuid)
|
||||||
|
if picture:
|
||||||
|
response["picture"] = picture
|
||||||
|
|
||||||
if "email" in scope and user.email:
|
if "email" in scope and user.email:
|
||||||
response["email"] = user.email
|
response["email"] = user.email
|
||||||
|
|||||||
+89
-2
@@ -3,11 +3,13 @@ from uuid import UUID
|
|||||||
from fastapi import (
|
from fastapi import (
|
||||||
Body,
|
Body,
|
||||||
FastAPI,
|
FastAPI,
|
||||||
|
File,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
|
UploadFile,
|
||||||
)
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.authsession import (
|
from paskia.authsession import (
|
||||||
@@ -18,12 +20,48 @@ from paskia.authsession import (
|
|||||||
from paskia.fastapi import authz, session
|
from paskia.fastapi import authz, session
|
||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil
|
from paskia.util import avatar, hostutil
|
||||||
from paskia.util.apistructs import ApiCreateLinkResponse
|
from paskia.util.apistructs import ApiCreateLinkResponse
|
||||||
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _can_manage_avatar(ctx, target_user) -> bool:
|
||||||
|
if ctx.user.uuid == target_user.uuid:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if any(p.scope == "auth:admin" for p in ctx.permissions):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return ctx.org.uuid == target_user.org.uuid and any(
|
||||||
|
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _avatar_write_ctx(request: Request, user_uuid: UUID, auth):
|
||||||
|
if not auth:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Authentication Required", mode="login"
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = session_ctx(auth, request.headers.get("host"))
|
||||||
|
if not ctx:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.data().users.get(user_uuid)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
if not _can_manage_avatar(ctx, user):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ctx, user
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(authz.AuthException)
|
@app.exception_handler(authz.AuthException)
|
||||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||||
"""Handle AuthException with auth info for UI."""
|
"""Handle AuthException with auth info for UI."""
|
||||||
@@ -103,6 +141,55 @@ async def user_update_info(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/{user_uuid}/profile.webp")
|
||||||
|
async def serve_avatar(request: Request, user_uuid: UUID):
|
||||||
|
"""Serve a user's current avatar with short-lived caching and ETag."""
|
||||||
|
user = db.data().users.get(user_uuid)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
path = avatar.avatar_path(user_uuid)
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
data = avatar.read_avatar_bytes(user_uuid)
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
etag = avatar.avatar_etag(data)
|
||||||
|
if request.headers.get("if-none-match") == f'"{etag}"':
|
||||||
|
return Response(status_code=304, headers={"ETag": f'"{etag}"'})
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"ETag": f'"{etag}"',
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileResponse(path, media_type="image/webp", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/{user_uuid}/profile.webp")
|
||||||
|
async def upload_avatar(
|
||||||
|
request: Request,
|
||||||
|
user_uuid: UUID,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Upload a user's browser-prepared WebP avatar on the same URL it is served from."""
|
||||||
|
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
|
||||||
|
data = await avatar.read_upload(file)
|
||||||
|
avatar.store_avatar(user_uuid, data)
|
||||||
|
return {"status": "ok", "avatar_url": avatar.avatar_browser_url(user_uuid)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{user_uuid}/profile.webp")
|
||||||
|
async def delete_avatar(request: Request, user_uuid: UUID, auth=AUTH_COOKIE):
|
||||||
|
"""Delete a user's avatar image on the same URL it is served from."""
|
||||||
|
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
|
||||||
|
avatar.remove_avatar_file(user_uuid)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/theme")
|
@app.patch("/theme")
|
||||||
async def user_update_theme(
|
async def user_update_theme(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -109,11 +109,17 @@ async def authenticate_and_login(
|
|||||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||||
|
|
||||||
# Use overrides if provided, otherwise use websocket metadata
|
# Use overrides if provided, otherwise use websocket metadata
|
||||||
login_host = hostutil.normalize_host(session_host) if session_host is not None else normalized_host
|
login_host = (
|
||||||
|
hostutil.normalize_host(session_host)
|
||||||
|
if session_host is not None
|
||||||
|
else normalized_host
|
||||||
|
)
|
||||||
if not login_host:
|
if not login_host:
|
||||||
raise ValueError("Host required for session creation")
|
raise ValueError("Host required for session creation")
|
||||||
login_ip = session_ip if session_ip is not None else metadata["ip"]
|
login_ip = session_ip if session_ip is not None else metadata["ip"]
|
||||||
login_user_agent = session_user_agent if session_user_agent is not None else metadata["user_agent"]
|
login_user_agent = (
|
||||||
|
session_user_agent if session_user_agent is not None else metadata["user_agent"]
|
||||||
|
)
|
||||||
|
|
||||||
# Create session and update user/credential
|
# Create session and update user/credential
|
||||||
secret = db.login(
|
secret = db.login(
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ class ApiUser(User, kw_only=True):
|
|||||||
"""User with uuid serialized."""
|
"""User with uuid serialized."""
|
||||||
|
|
||||||
uuid: UUID
|
uuid: UUID
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db(cls, u: User) -> ApiUser:
|
def from_db(cls, u: User, *, avatar_url: str | None = None) -> ApiUser:
|
||||||
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
|
return cls(uuid=u.uuid, avatar_url=avatar_url, **msgspec.structs.asdict(u))
|
||||||
|
|
||||||
|
|
||||||
class ApiOrg(Org, kw_only=True):
|
class ApiOrg(Org, kw_only=True):
|
||||||
@@ -139,7 +140,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
|
|||||||
user: ApiUser
|
user: ApiUser
|
||||||
credentials: dict[UUID, Credential]
|
credentials: dict[UUID, Credential]
|
||||||
aaguid_info: dict[str, ApiAaguidInfo]
|
aaguid_info: dict[str, ApiAaguidInfo]
|
||||||
sessions: dict[bytes, ApiUserSession]
|
sessions: dict[str, ApiUserSession]
|
||||||
permissions: dict[UUID, ApiPermission] = {}
|
permissions: dict[UUID, ApiPermission] = {}
|
||||||
org: ApiOrg | None = None
|
org: ApiOrg | None = None
|
||||||
role: ApiRole | None = None
|
role: ApiRole | None = None
|
||||||
@@ -156,7 +157,7 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
|||||||
org: ApiOrg
|
org: ApiOrg
|
||||||
permissions: dict[UUID, Permission]
|
permissions: dict[UUID, Permission]
|
||||||
roles: dict[UUID, Role]
|
roles: dict[UUID, Role]
|
||||||
users: dict[UUID, User]
|
users: dict[UUID, ApiUser]
|
||||||
|
|
||||||
|
|
||||||
class ApiSettings(msgspec.Struct):
|
class ApiSettings(msgspec.Struct):
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Avatar storage and URL helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
|
||||||
|
from paskia.db.paths import users_root_path
|
||||||
|
from paskia.util import hostutil
|
||||||
|
|
||||||
|
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def media_root() -> Path:
|
||||||
|
"""Return the filesystem root for auxiliary media files."""
|
||||||
|
return users_root_path(create_root=True)
|
||||||
|
|
||||||
|
|
||||||
|
def avatars_root() -> Path:
|
||||||
|
"""Return the filesystem root for stored avatar images."""
|
||||||
|
return media_root()
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_path(user_uuid: UUID) -> Path:
|
||||||
|
"""Return the avatar file path for a user."""
|
||||||
|
return avatars_root() / str(user_uuid) / "profile.webp"
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_public_path(user_uuid: UUID) -> str:
|
||||||
|
"""Return the public relative path for a user's avatar."""
|
||||||
|
return f"/auth/api/user/{user_uuid}/profile.webp"
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_browser_url(user_uuid: UUID) -> str | None:
|
||||||
|
"""Return the browser-facing avatar URL."""
|
||||||
|
if not avatar_path(user_uuid).is_file():
|
||||||
|
return None
|
||||||
|
return avatar_public_path(user_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_url(user_uuid: UUID) -> str | None:
|
||||||
|
"""Return the absolute public avatar URL for a user, or None."""
|
||||||
|
if not avatar_path(user_uuid).is_file():
|
||||||
|
return None
|
||||||
|
return hostutil.api_url(f"user/{user_uuid}/profile.webp")
|
||||||
|
|
||||||
|
|
||||||
|
def current_avatar_url(user_uuid: UUID) -> str | None:
|
||||||
|
"""Return the current absolute avatar URL for a user UUID."""
|
||||||
|
return avatar_url(user_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_avatar_file(user_uuid: UUID) -> None:
|
||||||
|
"""Delete a stored avatar file if it exists."""
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
avatar_path(user_uuid).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def read_avatar_bytes(user_uuid: UUID) -> bytes | None:
|
||||||
|
"""Read the stored avatar file for a user, if present."""
|
||||||
|
path = avatar_path(user_uuid)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_webp(data: bytes) -> bool:
|
||||||
|
"""Return True when bytes look like a RIFF WebP file."""
|
||||||
|
return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP"
|
||||||
|
|
||||||
|
|
||||||
|
async def read_upload(upload: UploadFile) -> bytes:
|
||||||
|
"""Read an uploaded avatar and require it to already be WebP."""
|
||||||
|
data = await upload.read(MAX_UPLOAD_BYTES + 1)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=400, detail="No avatar file uploaded")
|
||||||
|
if len(data) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Avatar upload too large")
|
||||||
|
|
||||||
|
if not _is_webp(data):
|
||||||
|
raise HTTPException(status_code=400, detail="Avatar upload must be WebP")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def store_avatar(user_uuid: UUID, data: bytes) -> None:
|
||||||
|
"""Store avatar bytes."""
|
||||||
|
path = avatar_path(user_uuid)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_etag(data: bytes) -> str:
|
||||||
|
"""Return a stable ETag value for avatar bytes."""
|
||||||
|
return hashlib.sha256(data).hexdigest()[:16]
|
||||||
@@ -29,6 +29,16 @@ def ui_base_path() -> str:
|
|||||||
return "/" if is_root_mode() else "/auth/"
|
return "/" if is_root_mode() else "/auth/"
|
||||||
|
|
||||||
|
|
||||||
|
def api_url(path: str = "") -> str:
|
||||||
|
"""Return an absolute URL under the canonical /auth/api/ prefix."""
|
||||||
|
cfg = _cfg()
|
||||||
|
base = cfg.site_url if cfg else "https://localhost"
|
||||||
|
if not path:
|
||||||
|
return f"{base}/auth/api/"
|
||||||
|
normalized = path.lstrip("/")
|
||||||
|
return f"{base}/auth/api/{normalized}"
|
||||||
|
|
||||||
|
|
||||||
def auth_site_url() -> str:
|
def auth_site_url() -> str:
|
||||||
"""Return the base URL for the auth site UI (computed at startup)."""
|
"""Return the base URL for the auth site UI (computed at startup)."""
|
||||||
cfg = _cfg()
|
cfg = _cfg()
|
||||||
|
|||||||
+25
-14
@@ -78,6 +78,7 @@ def create_id_token(
|
|||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
preferred_username: str | None = None,
|
preferred_username: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
|
picture: str | None = None,
|
||||||
groups: list[str] | None = None,
|
groups: list[str] | None = None,
|
||||||
auth_time: datetime | None = None,
|
auth_time: datetime | None = None,
|
||||||
expires_in: int = 3600,
|
expires_in: int = 3600,
|
||||||
@@ -93,6 +94,7 @@ def create_id_token(
|
|||||||
name: User's display name
|
name: User's display name
|
||||||
preferred_username: User's preferred username
|
preferred_username: User's preferred username
|
||||||
email: User's email address
|
email: User's email address
|
||||||
|
picture: User avatar URL
|
||||||
groups: List of permission scopes (groups claim)
|
groups: List of permission scopes (groups claim)
|
||||||
auth_time: When the user authenticated (last credential use time)
|
auth_time: When the user authenticated (last credential use time)
|
||||||
expires_in: Token lifetime in seconds
|
expires_in: Token lifetime in seconds
|
||||||
@@ -101,8 +103,9 @@ def create_id_token(
|
|||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_ensure_key()
|
||||||
|
assert _private_key is not None
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
"sub": str(subject),
|
"sub": str(subject),
|
||||||
"aud": audience,
|
"aud": audience,
|
||||||
@@ -119,6 +122,8 @@ def create_id_token(
|
|||||||
payload["preferred_username"] = preferred_username
|
payload["preferred_username"] = preferred_username
|
||||||
if email:
|
if email:
|
||||||
payload["email"] = email
|
payload["email"] = email
|
||||||
|
if picture:
|
||||||
|
payload["picture"] = picture
|
||||||
if groups:
|
if groups:
|
||||||
payload["groups"] = groups
|
payload["groups"] = groups
|
||||||
if auth_time:
|
if auth_time:
|
||||||
@@ -147,8 +152,9 @@ def create_access_token(
|
|||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_ensure_key()
|
||||||
|
assert _private_key is not None
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
"sub": str(subject),
|
"sub": str(subject),
|
||||||
"aud": audience,
|
"aud": audience,
|
||||||
@@ -173,20 +179,24 @@ def decode_access_token(
|
|||||||
Decoded payload or None if invalid
|
Decoded payload or None if invalid
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_ensure_key()
|
||||||
|
assert _public_key is not None
|
||||||
try:
|
try:
|
||||||
# PyJWT requires audience parameter when token has aud claim.
|
|
||||||
# When audience is None, we skip PyJWT's audience validation and validate manually.
|
|
||||||
options = {}
|
|
||||||
decode_kwargs = {
|
|
||||||
"algorithms": ["EdDSA"],
|
|
||||||
"issuer": issuer,
|
|
||||||
}
|
|
||||||
if audience is not None:
|
if audience is not None:
|
||||||
decode_kwargs["audience"] = audience
|
return jwt.decode(
|
||||||
else:
|
token,
|
||||||
options["verify_aud"] = False
|
_public_key,
|
||||||
|
algorithms=["EdDSA"],
|
||||||
|
issuer=issuer,
|
||||||
|
audience=audience,
|
||||||
|
)
|
||||||
|
|
||||||
return jwt.decode(token, _public_key, options=options, **decode_kwargs)
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
_public_key,
|
||||||
|
algorithms=["EdDSA"],
|
||||||
|
issuer=issuer,
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -212,8 +222,9 @@ def create_logout_token(
|
|||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_ensure_key()
|
||||||
|
assert _private_key is not None
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
"aud": audience,
|
"aud": audience,
|
||||||
"iat": int(now.timestamp()),
|
"iat": int(now.timestamp()),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from paskia import aaguid, db
|
from paskia import aaguid, db
|
||||||
from paskia.db import SessionContext
|
from paskia.db import SessionContext
|
||||||
from paskia.util import hostutil
|
from paskia.util import avatar, hostutil
|
||||||
from paskia.util.apistructs import (
|
from paskia.util.apistructs import (
|
||||||
ApiAaguidInfo,
|
ApiAaguidInfo,
|
||||||
ApiOrg,
|
ApiOrg,
|
||||||
@@ -56,7 +56,7 @@ async def build_user_info(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ApiUserDetail(
|
return ApiUserDetail(
|
||||||
user=ApiUser.from_db(user),
|
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||||
credentials={c.uuid: c for c in user.credentials},
|
credentials={c.uuid: c for c in user.credentials},
|
||||||
aaguid_info={
|
aaguid_info={
|
||||||
k: ApiAaguidInfo(**v)
|
k: ApiAaguidInfo(**v)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
"msgspec>=0.20.0",
|
"msgspec>=0.20.0",
|
||||||
"fastapi-vue>=1.1.0",
|
"fastapi-vue>=1.1.0",
|
||||||
"ua-parser[regex]>=1.0.1",
|
"ua-parser[regex]>=1.0.1",
|
||||||
|
"kanta>=0.1.1",
|
||||||
]
|
]
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
+39
-9
@@ -22,13 +22,13 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from kanta import Kanta
|
||||||
|
|
||||||
import paskia.db.operations as ops_db
|
import paskia.db.operations as ops_db
|
||||||
from paskia import globals as paskia_globals
|
from paskia import globals as paskia_globals
|
||||||
from paskia.authsession import reset_expires
|
from paskia.authsession import reset_expires
|
||||||
from paskia.config import SESSION_LIFETIME
|
from paskia.config import SESSION_LIFETIME
|
||||||
from paskia.db import (
|
from paskia.db import (
|
||||||
Config,
|
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
@@ -40,7 +40,7 @@ from paskia.db import (
|
|||||||
create_role,
|
create_role,
|
||||||
create_user,
|
create_user,
|
||||||
)
|
)
|
||||||
from paskia.db.jsonl import JsonlStore
|
from paskia.db.migrations import MigrationCtx
|
||||||
from paskia.db.operations import DB
|
from paskia.db.operations import DB
|
||||||
from paskia.db.structs import Session
|
from paskia.db.structs import Session
|
||||||
from paskia.fastapi.mainapp import app
|
from paskia.fastapi.mainapp import app
|
||||||
@@ -59,7 +59,7 @@ def event_loop():
|
|||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def test_db() -> AsyncGenerator[DB, None]:
|
async def test_db() -> AsyncGenerator[DB, None]:
|
||||||
"""Create an in-memory JSON database for testing.
|
"""Create a temporary JSONL database for testing using kanta.
|
||||||
|
|
||||||
Uses bootstrap() to properly initialize the database with:
|
Uses bootstrap() to properly initialize the database with:
|
||||||
- auth:admin and auth:org:admin permissions
|
- auth:admin and auth:org:admin permissions
|
||||||
@@ -67,18 +67,24 @@ async def test_db() -> AsyncGenerator[DB, None]:
|
|||||||
- An admin user with the Administration role
|
- An admin user with the Administration role
|
||||||
"""
|
"""
|
||||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||||
db = DB(config=Config(rp_id="test.example.com"))
|
db = DB()
|
||||||
store = JsonlStore(db, f.name)
|
kanta = Kanta(
|
||||||
db._store = store
|
f.name,
|
||||||
await store.load()
|
db,
|
||||||
|
migrations="paskia.db.migrations",
|
||||||
|
migration_ctx=MigrationCtx(rp_id="test.example.com"),
|
||||||
|
)
|
||||||
|
await kanta.open()
|
||||||
|
ops_db._store = kanta
|
||||||
ops_db._db = db
|
ops_db._db = db
|
||||||
ops_db._store = store
|
ops_db._db._store = kanta
|
||||||
# Bootstrap creates the initial permissions, org, role, and admin user
|
# Bootstrap creates the initial permissions, org, role, and admin user
|
||||||
bootstrap(
|
bootstrap(
|
||||||
org_name="Test Organization",
|
org_name="Test Organization",
|
||||||
admin_name="Test Admin",
|
admin_name="Test Admin",
|
||||||
)
|
)
|
||||||
yield db
|
yield ops_db._db
|
||||||
|
await kanta.close()
|
||||||
ops_db._db = None
|
ops_db._db = None
|
||||||
ops_db._store = None
|
ops_db._store = None
|
||||||
|
|
||||||
@@ -283,3 +289,27 @@ def create_test_session(
|
|||||||
with ops_db._db.transaction("create_test_session"):
|
with ops_db._db.transaction("create_test_session"):
|
||||||
session.store(now)
|
session.store(now)
|
||||||
return session.key, token
|
return session.key, token
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_image_bytes(
|
||||||
|
*,
|
||||||
|
image_format: str = "WEBP",
|
||||||
|
) -> bytes:
|
||||||
|
"""Return deterministic test upload bytes without image-library dependencies."""
|
||||||
|
fixtures = {
|
||||||
|
"WEBP": (
|
||||||
|
b"RIFF\x1a\x00\x00\x00WEBPVP8 "
|
||||||
|
b"\x0e\x00\x00\x000\x01\x00\x9d\x01*\x01\x00\x01\x00\x01\x00"
|
||||||
|
),
|
||||||
|
"PNG": (
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
b"\x00\x00\x00\rIHDR"
|
||||||
|
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
|
||||||
|
b"\x90wS\xde"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return fixtures[image_format.upper()]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Unsupported test image format: {image_format}") from exc
|
||||||
|
|||||||
+62
-1
@@ -14,6 +14,7 @@ These tests cover:
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urlsplit
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -37,7 +38,7 @@ from paskia.db import (
|
|||||||
)
|
)
|
||||||
from paskia.db.operations import DB
|
from paskia.db.operations import DB
|
||||||
from paskia.util.crypto import hash_secret
|
from paskia.util.crypto import hash_secret
|
||||||
from tests.conftest import auth_headers, create_test_session
|
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||||
|
|
||||||
# -------------------- Additional Fixtures --------------------
|
# -------------------- Additional Fixtures --------------------
|
||||||
|
|
||||||
@@ -238,6 +239,38 @@ class TestAdminOrganizations:
|
|||||||
assert "roles" in org_data
|
assert "roles" in org_data
|
||||||
assert "users" in org_data
|
assert "users" in org_data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_orgs_includes_user_avatar_urls(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_org,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Admin org payload should include canonical avatar URLs for listed users."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/auth/api/admin/info",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
listed_user = data["orgs"][str(test_org.uuid)]["users"][str(test_user.uuid)]
|
||||||
|
parts = urlsplit(listed_user["avatar_url"])
|
||||||
|
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||||
|
assert parts.query == ""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_orgs_with_org_admin(
|
async def test_list_orgs_with_org_admin(
|
||||||
self,
|
self,
|
||||||
@@ -902,6 +935,34 @@ class TestAdminUsersInOrg:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "display_name too long" in data["detail"]
|
assert "display_name too long" in data["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_can_upload_user_avatar(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user: User,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Admin should be able to upload avatar for a managed user."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
detail = await client.get(
|
||||||
|
f"/auth/api/admin/users/{test_user.uuid}",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert detail.status_code == 200
|
||||||
|
avatar_url = detail.json()["user"]["avatar_url"]
|
||||||
|
parts = urlsplit(avatar_url)
|
||||||
|
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_user_role_in_org(
|
async def test_update_user_role_in_org(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+155
-1
@@ -12,6 +12,8 @@ These tests cover:
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -19,8 +21,10 @@ import pytest
|
|||||||
from paskia import authcode
|
from paskia import authcode
|
||||||
from paskia.authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
from paskia.db import delete_session
|
from paskia.db import delete_session
|
||||||
|
from paskia.db.structs import Client
|
||||||
|
from paskia.util import avatar, hostutil, oidjwt
|
||||||
from paskia.util.passphrase import generate
|
from paskia.util.passphrase import generate
|
||||||
from tests.conftest import auth_headers, create_test_session
|
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsEndpoint:
|
class TestSettingsEndpoint:
|
||||||
@@ -46,6 +50,41 @@ class TestSettingsEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "ui_base_path" in data
|
assert "ui_base_path" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openid_configuration_includes_picture_claim(
|
||||||
|
self, client: httpx.AsyncClient
|
||||||
|
):
|
||||||
|
"""Discovery document should advertise picture claim support."""
|
||||||
|
response = await client.get("/.well-known/openid-configuration")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "picture" in response.json()["claims_supported"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAvatarUrls:
|
||||||
|
"""Tests for avatar URL helpers."""
|
||||||
|
|
||||||
|
def test_avatar_url_uses_canonical_public_path_in_auth_host_mode(
|
||||||
|
self, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Absolute avatar URLs should preserve /auth/api even with an auth host."""
|
||||||
|
db_root = tmp_path / "test-avatar-db.paskiadb"
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(db_root))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
hostutil,
|
||||||
|
"api_url",
|
||||||
|
lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||||
|
path = db_root / "users" / str(test_uuid) / "profile.webp"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"RIFF1234WEBP")
|
||||||
|
|
||||||
|
assert avatar.avatar_url(user_uuid) == (
|
||||||
|
"https://auth.zi.fi/auth/api/user/"
|
||||||
|
"019c6831-84cf-7b88-b66c-c8165890b7c5/profile.webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestValidateEndpoint:
|
class TestValidateEndpoint:
|
||||||
"""Tests for POST /auth/api/validate"""
|
"""Tests for POST /auth/api/validate"""
|
||||||
@@ -294,6 +333,121 @@ class TestUserInfoEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "permissions" in data
|
assert "permissions" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_info_includes_avatar_url(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""User info should include the canonical avatar URL when present."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/auth/api/user-info",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
avatar_url = data["user"]["avatar_url"]
|
||||||
|
parts = urlsplit(avatar_url)
|
||||||
|
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||||
|
assert parts.query == ""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_avatar_route_returns_304_for_matching_etag(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Avatar route should honor If-None-Match for unchanged avatars."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
parts = urlsplit(upload.json()["avatar_url"])
|
||||||
|
|
||||||
|
first = await client.get(parts.path, headers={"Host": "localhost:4401"})
|
||||||
|
assert first.status_code == 200
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
headers={
|
||||||
|
"Host": "localhost:4401",
|
||||||
|
"If-None-Match": first.headers["etag"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 304
|
||||||
|
assert response.headers["etag"] == first.headers["etag"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOidcUserInfoEndpoint:
|
||||||
|
"""Tests for OIDC userinfo metadata relevant to avatars."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_userinfo_includes_picture_claim(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
test_db,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""OIDC userinfo should expose picture when profile scope is granted."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
avatar_url = upload.json()["avatar_url"]
|
||||||
|
|
||||||
|
oidc_client, _secret = Client.create(
|
||||||
|
name="Test Client",
|
||||||
|
redirect_uris=["https://client.example/callback"],
|
||||||
|
client_secret="topsecret",
|
||||||
|
)
|
||||||
|
with test_db.transaction("create_test_oidc_client"):
|
||||||
|
test_db.oidc.clients[oidc_client.uuid] = oidc_client
|
||||||
|
|
||||||
|
access_token = oidjwt.create_access_token(
|
||||||
|
issuer="http://localhost:4401",
|
||||||
|
subject=test_user.uuid,
|
||||||
|
audience=str(oidc_client.uuid),
|
||||||
|
scope="openid profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/auth/oidc/userinfo",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Host": "localhost:4401",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert urlsplit(data["picture"]).path == urlsplit(avatar_url).path
|
||||||
|
assert data["picture"].startswith("http")
|
||||||
|
|
||||||
|
|
||||||
class TestSetSessionEndpoint:
|
class TestSetSessionEndpoint:
|
||||||
"""Tests for POST /auth/api/set-session"""
|
"""Tests for POST /auth/api/set-session"""
|
||||||
|
|||||||
+161
-1
@@ -3,16 +3,20 @@ Tests for the user API endpoints (/auth/api/user/).
|
|||||||
|
|
||||||
These tests cover user self-service operations:
|
These tests cover user self-service operations:
|
||||||
- Display name update
|
- Display name update
|
||||||
|
- Avatar upload/delete
|
||||||
- Logout all sessions
|
- Logout all sessions
|
||||||
- Session management (delete specific session)
|
- Session management (delete specific session)
|
||||||
- Credential management (delete credential)
|
- Credential management (delete credential)
|
||||||
- Device addition link creation
|
- Device addition link creation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.conftest import auth_headers
|
from paskia.db.paths import db_file_path, users_root_path
|
||||||
|
from tests.conftest import auth_headers, create_test_image_bytes
|
||||||
|
|
||||||
|
|
||||||
class TestUserDisplayName:
|
class TestUserDisplayName:
|
||||||
@@ -67,6 +71,162 @@ class TestUserDisplayName:
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserAvatar:
|
||||||
|
"""Tests for PUT/DELETE /auth/api/user/{user_uuid}/profile.webp"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_avatar_requires_auth(self, client: httpx.AsyncClient):
|
||||||
|
"""Uploading avatar without auth should return 401."""
|
||||||
|
response = await client.put(
|
||||||
|
"/auth/api/user/00000000-0000-0000-0000-000000000000/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 404)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_avatar_success(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Uploading a WebP avatar should store and expose the canonical URL."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload_bytes = create_test_image_bytes()
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", upload_bytes, "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
avatar_url = data["avatar_url"]
|
||||||
|
parts = urlsplit(avatar_url)
|
||||||
|
assert parts.query == ""
|
||||||
|
|
||||||
|
avatar_response = await client.get(
|
||||||
|
parts.path,
|
||||||
|
headers={"Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert avatar_response.status_code == 200
|
||||||
|
assert avatar_response.headers["cache-control"] == "public, max-age=300"
|
||||||
|
assert avatar_response.headers["content-type"] == "image/webp"
|
||||||
|
assert "etag" in avatar_response.headers
|
||||||
|
assert avatar_response.content == upload_bytes
|
||||||
|
|
||||||
|
not_modified = await client.get(
|
||||||
|
parts.path,
|
||||||
|
headers={
|
||||||
|
"Host": "localhost:4401",
|
||||||
|
"If-None-Match": avatar_response.headers["etag"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert not_modified.status_code == 304
|
||||||
|
assert not_modified.headers["etag"] == avatar_response.headers["etag"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_avatar_rejects_non_webp(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Avatar uploads must already be browser-prepared WebP."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={
|
||||||
|
"file": (
|
||||||
|
"avatar.png",
|
||||||
|
create_test_image_bytes(image_format="PNG"),
|
||||||
|
"image/png",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "Avatar upload must be WebP"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_avatar_success(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Deleting avatar should clear the user avatar URL."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.delete(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
info = await client.get(
|
||||||
|
"/auth/api/user-info",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert info.status_code == 200
|
||||||
|
assert info.json()["user"].get("avatar_url") is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_regular_user_cannot_upload_another_users_avatar(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
regular_session_token: str,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
):
|
||||||
|
"""A non-admin user should not be able to upload another user's avatar."""
|
||||||
|
response = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_paskia_db_legacy_file_is_migrated_to_root_dir(tmp_path, monkeypatch):
|
||||||
|
legacy_path = tmp_path / "legacy.paskiadb"
|
||||||
|
legacy_bytes = b'{"v":0}\n'
|
||||||
|
legacy_path.write_bytes(legacy_bytes)
|
||||||
|
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(legacy_path))
|
||||||
|
|
||||||
|
db_path = db_file_path(create_root=True)
|
||||||
|
|
||||||
|
assert legacy_path.is_dir()
|
||||||
|
assert db_path == legacy_path / "main.db"
|
||||||
|
assert db_path.read_bytes() == legacy_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_paskia_db_root_uses_users_directory(tmp_path, monkeypatch):
|
||||||
|
root_path = tmp_path / "instance-root"
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(root_path))
|
||||||
|
|
||||||
|
users_path = users_root_path(create_root=True)
|
||||||
|
|
||||||
|
assert users_path == root_path / "users"
|
||||||
|
assert users_path.parent == root_path
|
||||||
|
|
||||||
|
|
||||||
class TestUserLogoutAll:
|
class TestUserLogoutAll:
|
||||||
"""Tests for POST /auth/api/user/logout-all"""
|
"""Tests for POST /auth/api/user/logout-all"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user