Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b28250391 | ||
|
|
b9aec6bb58 | ||
|
|
c79cb497ee | ||
|
|
9f50c8c20d | ||
|
|
816c7a681e | ||
|
|
d31c09084e | ||
|
|
cc938dd306 | ||
|
|
36db1e7e56 | ||
|
|
95c163e37a | ||
|
|
2d0d17c307 | ||
|
|
10980ad39b | ||
|
|
42b54cf645 | ||
|
|
232d0e1ae0 | ||
|
|
e97a2b3291 | ||
|
|
cde709e252 | ||
|
|
72d76df35d | ||
|
|
1a742fc0e7 | ||
|
|
0b29654d6f | ||
|
|
76f24a755b | ||
|
|
5c452f325a | ||
|
|
e9b6bc7a3d | ||
|
|
f5545b48f0 |
@@ -6,6 +6,7 @@ dist/
|
||||
package-lock.json
|
||||
paskia.sqlite
|
||||
*.paskiadb
|
||||
*.data
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
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 |
|
||||
| --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
|
||||
|
||||
|
||||
+8
-2
@@ -26,13 +26,17 @@ The `validate` and `forward` endpoints take query arguments `perm=` and `max_age
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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/*`
|
||||
|
||||
@@ -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 |
|
||||
| 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/*`
|
||||
|
||||
| Path | Used for | Notes |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execSync, spawn } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -58,6 +58,11 @@ export default async function globalSetup() {
|
||||
// Use a fresh database file for tests
|
||||
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
|
||||
const serverProcess = spawn('uv', serverArgs, {
|
||||
cwd: projectRoot,
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function globalTeardown() {
|
||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing test database...')
|
||||
rmSync(testDbFile)
|
||||
rmSync(testDbFile, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
// Generate Python coverage report if coverage was collected
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, SessionValidator } from 'paskia'
|
||||
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
@@ -72,8 +72,8 @@ async function loadUserInfo() {
|
||||
// apiJson handles 401/403 with auth.iframe automatically:
|
||||
// shows overlay iframe, waits for auth, retries the request.
|
||||
const [validateData, userInfoData] = await Promise.all([
|
||||
apiJson('/auth/api/validate', { method: 'POST' }),
|
||||
apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||
])
|
||||
store.userInfo = userInfoData
|
||||
store.ctx = validateData.ctx
|
||||
|
||||
@@ -13,7 +13,7 @@ import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
|
||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { apiJson, SessionValidator } from 'paskia'
|
||||
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import { uuidv7 } from 'uuidv7'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
@@ -196,7 +196,7 @@ function orgUserCount(org) {
|
||||
}
|
||||
|
||||
async function loadUserInfo() {
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
info.value = data
|
||||
updateThemeFromSession(data.ctx)
|
||||
authenticated.value = true
|
||||
@@ -796,7 +796,7 @@ async function submitDialog() {
|
||||
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
const status = reactive({
|
||||
@@ -164,7 +164,8 @@ async function exchangeCode(result) {
|
||||
}
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
|
||||
timeout: paskiaSettings.auth_ms,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -71,10 +72,6 @@ function onUserChange(evt, targetRoleUuid) {
|
||||
}
|
||||
}
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
}
|
||||
|
||||
function toggleRolePermission(role, pid, checked) {
|
||||
emit('toggleRolePermission', role, pid, checked)
|
||||
}
|
||||
@@ -400,8 +397,19 @@ defineExpose({ focusFirstElement })
|
||||
@keydown.enter="$emit('openUser', u)"
|
||||
: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="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
@@ -422,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 .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; }
|
||||
.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-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); }
|
||||
@@ -430,9 +438,12 @@ defineExpose({ focusFirstElement })
|
||||
.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 { 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 .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-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; }
|
||||
|
||||
@@ -15,14 +15,11 @@ const props = defineProps({
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgSection = ref(null)
|
||||
const orgActionsRef = ref(null)
|
||||
const orgTableRef = ref(null)
|
||||
const permMatrixRef = ref(null)
|
||||
const permActionsRef = ref(null)
|
||||
const permTableRef = ref(null)
|
||||
const oidcActionsRef = ref(null)
|
||||
const oidcTableRef = ref(null)
|
||||
|
||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
|
||||
@@ -62,10 +59,6 @@ const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.s
|
||||
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
|
||||
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
}
|
||||
|
||||
function getRoleNames(org) {
|
||||
// org.roles is dict[UUID, Role]
|
||||
return Object.values(org.roles)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -23,6 +24,8 @@ const authStore = useAuthStore()
|
||||
const terminatingSessions = ref({})
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const showPictureDialog = ref(false)
|
||||
const avatarRenderVersion = ref(0)
|
||||
|
||||
// Convert credentials dict to array with uuid attached as 'credential'
|
||||
const credentials = computed(() =>
|
||||
@@ -48,6 +51,20 @@ function handleEditName() {
|
||||
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) {
|
||||
try {
|
||||
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()
|
||||
|
||||
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') {
|
||||
emit('navigateOut', 'up')
|
||||
} else if (direction === 'down') {
|
||||
@@ -124,7 +141,7 @@ function handleRegActionsKeydown(event) {
|
||||
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
||||
} else if (direction === 'up') {
|
||||
// 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') {
|
||||
// Move to credential list
|
||||
credentialListRef.value?.$el?.focus()
|
||||
@@ -174,10 +191,22 @@ function handleBackButtonKeydown(event) {
|
||||
|
||||
// Focus helper for external navigation
|
||||
function focusFirstElement() {
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -186,6 +215,9 @@ defineExpose({ focusFirstElement })
|
||||
<UserBasicInfo
|
||||
v-if="userDetail && !userDetail.error"
|
||||
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||
:avatar-url="userDetail.user.avatar_url"
|
||||
:avatar-render-version="avatarRenderVersion"
|
||||
avatar-clickable
|
||||
:visits="userDetail.user.visits"
|
||||
:created-at="userDetail.user.created_at"
|
||||
:last-seen="userDetail.user.last_seen"
|
||||
@@ -196,6 +228,7 @@ defineExpose({ focusFirstElement })
|
||||
:role-name="userDetail.role.display_name"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@avatar-click="openPictureDialog"
|
||||
@edit="handleEditName"
|
||||
>
|
||||
<div class="admin-actions">
|
||||
@@ -258,6 +291,15 @@ defineExpose({ focusFirstElement })
|
||||
@close="$emit('closeRegModal')"
|
||||
@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>
|
||||
</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
|
||||
v-if="ctx"
|
||||
:name="ctx.user.display_name"
|
||||
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||
:visits="authStore.userInfo.user.visits"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,7 +17,9 @@ const props = defineProps({
|
||||
// Optional: index to help find next sibling when item is deleted
|
||||
focusIndex: { type: Number, default: -1 },
|
||||
// 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'])
|
||||
|
||||
@@ -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"
|
||||
ref="userBasicInfo"
|
||||
:name="authStore.userInfo.user.display_name"
|
||||
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||
:avatar-render-version="avatarRenderVersion"
|
||||
avatar-clickable
|
||||
:email="authStore.userInfo.user.email"
|
||||
:preferred_username="authStore.userInfo.user.preferred_username"
|
||||
:telephone="authStore.userInfo.user.telephone"
|
||||
@@ -26,6 +29,7 @@
|
||||
:role-name="authStore.userInfo.role.display_name"
|
||||
update-endpoint="/auth/api/user/info"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@avatar-click="openAvatarDialog"
|
||||
@edit="openEditDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
>
|
||||
@@ -131,6 +135,15 @@
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ProfilePictureEditorModal
|
||||
v-if="showAvatarDialog && currentAvatarEndpoint"
|
||||
:endpoint="currentAvatarEndpoint"
|
||||
:picture-url="authStore.userInfo?.user?.avatar_url"
|
||||
:render-version="avatarRenderVersion"
|
||||
@close="closeAvatarDialog"
|
||||
@updated="handleProfilePictureUpdated"
|
||||
/>
|
||||
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegLink"
|
||||
endpoint="/auth/api/user/create-link"
|
||||
@@ -144,6 +157,7 @@
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
@@ -160,11 +174,13 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showEditDialog = ref(false)
|
||||
const showAvatarDialog = ref(false)
|
||||
const showRegLink = ref(false)
|
||||
const editName = ref('')
|
||||
const editEmail = ref('')
|
||||
const editUsername = ref('')
|
||||
const editTelephone = ref('')
|
||||
const avatarRenderVersion = ref(0)
|
||||
const saving = ref(false)
|
||||
const editError = ref('')
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
@@ -176,14 +192,15 @@ const credentialButtons = ref(null)
|
||||
const sessionList = ref(null)
|
||||
const logoutButtons = ref(null)
|
||||
const breadcrumbs = ref(null)
|
||||
const userBasicInfo = ref(null)
|
||||
const userInfoSection = ref(null)
|
||||
|
||||
// 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) => {
|
||||
if (!open) return
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const user = authStore.userInfo.user
|
||||
editName.value = user.display_name ?? ''
|
||||
editEmail.value = user.email ?? ''
|
||||
@@ -196,7 +213,28 @@ onMounted(() => {
|
||||
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 () => {
|
||||
try {
|
||||
@@ -245,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
|
||||
if (direction === 'down') {
|
||||
event.preventDefault()
|
||||
// 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
|
||||
}
|
||||
@@ -257,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
|
||||
if (!direction) return
|
||||
|
||||
event.preventDefault()
|
||||
const itemSelector = '.mini-btn, .pairing-input'
|
||||
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
|
||||
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
||||
@@ -278,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
|
||||
focusPreferredButton(credentialButtons.value)
|
||||
} else if (direction === 'up' || direction === 'left') {
|
||||
// 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 {
|
||||
editError.value = ''
|
||||
saving.value = true
|
||||
let changed = false
|
||||
const body = {}
|
||||
if (name !== user.display_name) body.display_name = name
|
||||
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||
@@ -406,6 +445,9 @@ const saveProfile = async () => {
|
||||
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||
if (Object.keys(body).length) {
|
||||
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||
import { focusDialogButton } from '@/utils/keynav'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
@@ -147,7 +147,7 @@ async function fetchSettings() {
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
updateThemeFromSession(session.value?.ctx)
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
@@ -198,7 +198,7 @@ async function logoutUser() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
session.value = null
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
@@ -220,7 +220,7 @@ async function exchangeCode(result) {
|
||||
throw new Error('Authentication response missing exchange_code')
|
||||
}
|
||||
return await fetchJson('/auth/api/set-session', {
|
||||
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<template>
|
||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||
<div class="user-info-content">
|
||||
<div class="user-picture">
|
||||
<span>👤</span>
|
||||
</div>
|
||||
<ProfilePicture
|
||||
:src="avatarUrl"
|
||||
: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">
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
@@ -42,11 +53,13 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
avatarUrl: { type: String, default: null },
|
||||
avatarRenderVersion: { type: [Number, String], default: 0 },
|
||||
email: { type: String, default: null },
|
||||
preferred_username: { type: String, default: null },
|
||||
telephone: { type: String, default: null },
|
||||
@@ -55,14 +68,13 @@ const props = defineProps({
|
||||
lastSeen: { type: [String, Number, Date], default: null },
|
||||
updateEndpoint: { type: String, default: null },
|
||||
canEdit: { type: Boolean, default: true },
|
||||
avatarClickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
orgDisplayName: { type: String, default: '' },
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'edit'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
</script>
|
||||
|
||||
@@ -96,12 +108,12 @@ const userLoaded = computed(() => !!props.name)
|
||||
grid-template-areas:
|
||||
"picture heading fields"
|
||||
"picture org fields"
|
||||
". info info";
|
||||
"picture info info";
|
||||
gap: 0 1rem;
|
||||
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; }
|
||||
.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; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { register, authenticate } from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { apiJson } from 'paskia'
|
||||
import { apiJson, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -50,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||
timeout: paskiaSettings.auth_ms,
|
||||
})
|
||||
},
|
||||
async register() {
|
||||
@@ -87,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||
updateThemeFromSession(this.userInfo)
|
||||
console.log('User info loaded:', this.userInfo)
|
||||
} catch (error) {
|
||||
@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await apiJson('/auth/api/logout', {method: 'POST'})
|
||||
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
@@ -134,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logoutEverywhere() {
|
||||
try {
|
||||
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
||||
await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
|
||||
@@ -75,10 +75,16 @@ Discovery: `backchannel_logout_supported: true`
|
||||
- `GET /.well-known/openid-configuration` — Discovery
|
||||
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
||||
- `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/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
|
||||
|
||||
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
||||
|
||||
@@ -64,6 +64,30 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
|
||||
|
||||
The JSON variants set headers automatically, with body and response in JSON.
|
||||
|
||||
### Timeout Settings
|
||||
|
||||
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
|
||||
|
||||
```js
|
||||
import { settings } from 'paskia'
|
||||
|
||||
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
|
||||
settings.fetch_ms = 10000
|
||||
|
||||
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
|
||||
settings.auth_ms = 1000
|
||||
|
||||
// SessionValidator polling and idle timers
|
||||
settings.poll_ms = 60000
|
||||
settings.idle_ms = 300000
|
||||
```
|
||||
|
||||
You can still override timeout per request:
|
||||
|
||||
```js
|
||||
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
|
||||
```
|
||||
|
||||
### Authentication Overlay
|
||||
|
||||
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paskia",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Paskia authentication utilities for JavaScript",
|
||||
"author": "Leo Vasanko",
|
||||
"license": "Unlicense",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { showAuthIframe, AuthCancelledError } from './overlay'
|
||||
import settings from './settings'
|
||||
|
||||
export { AuthCancelledError }
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 1000
|
||||
|
||||
export interface ApiFetchOptions extends RequestInit {
|
||||
timeout?: number
|
||||
}
|
||||
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
|
||||
}
|
||||
|
||||
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
|
||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
||||
const { timeout = settings.fetch_ms, ...fetchOptions } = options
|
||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||
|
||||
while (true) {
|
||||
|
||||
@@ -12,6 +12,8 @@ export {
|
||||
|
||||
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
|
||||
|
||||
export { default as settings } from './settings'
|
||||
|
||||
export {
|
||||
holdGlobalBackdrop,
|
||||
releaseGlobalBackdrop,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
fetch_ms: 10000,
|
||||
auth_ms: 1000,
|
||||
poll_ms: 60000,
|
||||
idle_ms: 300000,
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { apiJson } from './fetch'
|
||||
|
||||
const POLL_INTERVAL = 60 * 1000
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
import settings from './settings'
|
||||
|
||||
export class SessionValidator {
|
||||
private userUuidGetter: () => string | undefined
|
||||
@@ -19,12 +17,12 @@ export class SessionValidator {
|
||||
resetIdleTimer(): void {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer)
|
||||
if (!this.active) this.startPolling()
|
||||
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
|
||||
this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
|
||||
}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
try {
|
||||
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
|
||||
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
|
||||
const newUuid = data.ctx?.user?.uuid
|
||||
if (newUuid !== this.userUuidGetter()) {
|
||||
window.location.reload()
|
||||
@@ -40,7 +38,7 @@ export class SessionValidator {
|
||||
startPolling(): void {
|
||||
if (this.active) return
|
||||
this.active = true
|
||||
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
|
||||
this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
|
||||
+47
-7
@@ -1,13 +1,20 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.db.jsonl import load_readonly
|
||||
from paskia._version import __version__
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.db.structs import DB, Config
|
||||
from paskia.util import startupbox
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import (
|
||||
normalize_auth_host_and_origins,
|
||||
normalize_origin,
|
||||
@@ -15,9 +22,6 @@ from paskia.util.hostutil import (
|
||||
)
|
||||
from paskia.util.runtime import RuntimeConfig
|
||||
|
||||
DEFAULT_PORT = 4401
|
||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||
|
||||
EPILOG = """\
|
||||
Example:
|
||||
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
|
||||
@@ -47,6 +51,36 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _load_stored_config(db_path: Path, *, rp_id: str) -> Config:
|
||||
"""Load the stored Config from disk using Kanta in read-only mode.
|
||||
|
||||
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
|
||||
If the database file does not exist, a default config is returned.
|
||||
"""
|
||||
if not db_path.exists():
|
||||
return Config(rp_id=rp_id)
|
||||
|
||||
kanta = Kanta(
|
||||
str(db_path),
|
||||
DB(config=Config(rp_id=rp_id)),
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = rp_id
|
||||
|
||||
async def _read() -> Config:
|
||||
await kanta.open(readonly=True)
|
||||
try:
|
||||
return kanta.data.config
|
||||
finally:
|
||||
await kanta.close()
|
||||
|
||||
try:
|
||||
return asyncio.run(_read())
|
||||
except Exception as e:
|
||||
logging.exception("Failed to load database")
|
||||
raise SystemExit(f"{e}") from e
|
||||
|
||||
|
||||
def main():
|
||||
# Configure logging to remove the "ERROR:root:" prefix
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
@@ -72,9 +106,15 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load stored config (read-only, no writes, no global state)
|
||||
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
||||
config = load_readonly(db_path, rp_id=args.rp_id).config
|
||||
# Load stored config using a local read-only Kanta instance.
|
||||
# This happens before PASKIA_CONFIG is set, so we must not import
|
||||
# modules that initialize the global database lifecycle.
|
||||
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
|
||||
try:
|
||||
config = _load_stored_config(db_path, rp_id=args.rp_id)
|
||||
except SystemExit as e:
|
||||
print(f"🛑 Paskia {__version__} could not load")
|
||||
sys.exit(str(e))
|
||||
|
||||
# Override stored config with CLI args, or clear with empty string
|
||||
if args.rp_name is not None:
|
||||
|
||||
+26
-40
@@ -4,47 +4,37 @@ Bootstrap module for passkey authentication system.
|
||||
This module handles initial system setup when a new database is created,
|
||||
including creating default admin user, organization, permissions, and
|
||||
generating a reset link for initial admin setup.
|
||||
|
||||
The actual database seeding is performed by the module-level kanta bootstrap
|
||||
callback defined in :mod:`paskia.db.bootstrap` and registered during
|
||||
:func:`paskia.db.lifecycle.init`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from paskia import authsession, db
|
||||
from paskia.db.bootstrap import log_reset_link
|
||||
from paskia.db.structs import Config
|
||||
from paskia.util import hostutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Shared log message template for admin reset links
|
||||
ADMIN_RESET_MESSAGE = """
|
||||
👤 Admin %s
|
||||
- Use this link to register a Passkey for the admin user!
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||
"""Log a reset link message and return the URL."""
|
||||
reset_link = hostutil.reset_link_url(passphrase)
|
||||
if message:
|
||||
logger.info(message)
|
||||
logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
||||
return reset_link
|
||||
|
||||
|
||||
async def bootstrap_system(config: Config | None = None) -> None:
|
||||
"""
|
||||
Bootstrap the entire system with default data.
|
||||
|
||||
Uses db.bootstrap() which performs all operations in a single transaction.
|
||||
The transaction log will show a single "bootstrap" action with all changes.
|
||||
|
||||
Args:
|
||||
config: Configuration to store (rp_id, rp_name, origins, etc.)
|
||||
"""
|
||||
# Call the single-transaction bootstrap function
|
||||
reset_passphrase = db.bootstrap(config=config)
|
||||
|
||||
# Log the reset link (this is separate from the transaction log)
|
||||
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||
return log_reset_link(passphrase, message)
|
||||
|
||||
|
||||
async def check_admin_credentials() -> bool:
|
||||
@@ -101,22 +91,18 @@ async def check_admin_credentials() -> bool:
|
||||
|
||||
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
||||
"""
|
||||
Check if system needs bootstrapping and perform it if necessary.
|
||||
Check if admin needs credentials and create a reset link if needed.
|
||||
|
||||
Database bootstrapping itself is now handled automatically during
|
||||
``db.init()`` via the registered kanta bootstrap callback. This function
|
||||
remains as a post-init hook for credential checks.
|
||||
|
||||
Args:
|
||||
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
|
||||
config: Kept for backwards compatibility; config is now applied during
|
||||
``db.init()``.
|
||||
|
||||
Returns:
|
||||
bool: True if bootstrapping was performed, False if system was already set up
|
||||
bool: Always returns False (bootstrapping is performed during init).
|
||||
"""
|
||||
# Check if the admin permission exists - if it does, system is already bootstrapped
|
||||
if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
|
||||
# Permission exists, system is already bootstrapped
|
||||
# Check if admin needs credentials (only for already-bootstrapped systems)
|
||||
await check_admin_credentials()
|
||||
return False
|
||||
|
||||
# No admin permission found, need to bootstrap
|
||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
||||
await bootstrap_system(config=config)
|
||||
return True
|
||||
|
||||
@@ -19,15 +19,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import paskia.db.operations as operations
|
||||
from paskia.db.background import (
|
||||
start_background,
|
||||
start_cleanup,
|
||||
stop_background,
|
||||
stop_cleanup,
|
||||
)
|
||||
from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.jsonl import load_readonly
|
||||
from paskia.db.lifecycle import cleanup_expired, init
|
||||
from paskia.db.operations import (
|
||||
add_permission_to_org,
|
||||
add_permission_to_role,
|
||||
@@ -101,19 +93,11 @@ __all__ = [
|
||||
"User",
|
||||
# Instance
|
||||
"data",
|
||||
"init",
|
||||
"load_readonly",
|
||||
# Background
|
||||
"start_background",
|
||||
"stop_background",
|
||||
"start_cleanup",
|
||||
"stop_cleanup",
|
||||
# Read ops
|
||||
# Write ops
|
||||
"add_permission_to_org",
|
||||
"add_permission_to_role",
|
||||
"bootstrap",
|
||||
"cleanup_expired",
|
||||
"create_credential",
|
||||
"create_credential_session",
|
||||
"create_org",
|
||||
|
||||
+9
-40
@@ -1,67 +1,38 @@
|
||||
"""
|
||||
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 logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia.db.lifecycle import cleanup_expired
|
||||
|
||||
FLUSH_INTERVAL = 0.1 # Flush to disk
|
||||
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_background_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def flush() -> None:
|
||||
"""Write all pending database changes to disk."""
|
||||
store = _ops._db._store
|
||||
if store is None:
|
||||
_logger.warning("flush() called but _store is None")
|
||||
return
|
||||
await store.flush()
|
||||
|
||||
|
||||
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
|
||||
cleanup_expired()
|
||||
await flush()
|
||||
|
||||
last_cleanup = datetime.now(UTC)
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(FLUSH_INTERVAL)
|
||||
# Flush pending changes to disk
|
||||
await flush()
|
||||
|
||||
# Run cleanup periodically
|
||||
now = datetime.now(UTC)
|
||||
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
||||
await asyncio.sleep(CLEANUP_INTERVAL)
|
||||
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:
|
||||
# Final flush before exit
|
||||
await flush()
|
||||
break
|
||||
except Exception:
|
||||
_logger.debug("Error in database background loop", exc_info=True)
|
||||
|
||||
|
||||
async def start_background():
|
||||
"""Start the background flush/cleanup task."""
|
||||
"""Start the background cleanup task."""
|
||||
global _background_task
|
||||
|
||||
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
||||
@@ -75,16 +46,15 @@ async def start_background():
|
||||
# Check if task is in current event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
task_loop = _background_task.get_loop()
|
||||
if loop is not task_loop:
|
||||
_logger.debug("Background task in different event loop, restarting")
|
||||
_background_task = None
|
||||
else:
|
||||
if loop is task_loop:
|
||||
# Task is already running in same loop - idempotent, just return
|
||||
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
||||
_logger.debug(
|
||||
"Background task already running in same loop, skipping"
|
||||
)
|
||||
return
|
||||
_logger.debug("Background task in different event loop, restarting")
|
||||
_background_task = None
|
||||
except Exception as e:
|
||||
_logger.debug("Error checking background task loop: %s, restarting", e)
|
||||
_background_task = None
|
||||
@@ -94,7 +64,7 @@ async def start_background():
|
||||
|
||||
|
||||
async def stop_background():
|
||||
"""Stop the background task, flush pending changes, and release the file lock."""
|
||||
"""Stop the background cleanup task."""
|
||||
global _background_task
|
||||
if _background_task:
|
||||
_background_task.cancel()
|
||||
@@ -103,7 +73,6 @@ async def stop_background():
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_background_task = None
|
||||
_ops._db._store.close()
|
||||
|
||||
|
||||
# Aliases for backwards compatibility
|
||||
|
||||
+51
-16
@@ -2,24 +2,60 @@
|
||||
Bootstrap operations for initial system setup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import uuid7
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
|
||||
from paskia.db.structs import DB, Config, Org, Permission, ResetToken, Role, User
|
||||
from paskia.util.crypto import secret_key
|
||||
from paskia.util.hostutil import reset_link_url
|
||||
|
||||
_reset_link_logger = logging.getLogger("paskia.reset_link")
|
||||
|
||||
|
||||
def _configure_reset_link_logger() -> None:
|
||||
if _reset_link_logger.handlers:
|
||||
return
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_reset_link_logger.addHandler(handler)
|
||||
_reset_link_logger.setLevel(logging.INFO)
|
||||
_reset_link_logger.propagate = False
|
||||
|
||||
|
||||
_configure_reset_link_logger()
|
||||
|
||||
ADMIN_RESET_MESSAGE = """
|
||||
👤 Admin %s
|
||||
- Use this link to register a Passkey for the admin user!
|
||||
"""
|
||||
|
||||
|
||||
def log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||
"""Log a reset link message and return the URL."""
|
||||
reset_link = reset_link_url(passphrase)
|
||||
if message:
|
||||
_reset_link_logger.info(message)
|
||||
_reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
||||
return reset_link
|
||||
|
||||
|
||||
def bootstrap(
|
||||
data: "DB",
|
||||
org_name: str = "Organization",
|
||||
admin_name: str = "Admin",
|
||||
reset_passphrase: str | None = None,
|
||||
reset_expiry: datetime | None = None,
|
||||
config: Config | None = None,
|
||||
) -> str:
|
||||
"""Bootstrap the entire system in a single transaction.
|
||||
"""Bootstrap the entire system by seeding an empty database.
|
||||
|
||||
This is intended to be called from a ``@kanta.bootstrap`` callback during
|
||||
``kanta.open()``. It mutates the provided root ``data`` object directly;
|
||||
kanta queues the resulting state as the initial "bootstrap" change record.
|
||||
|
||||
Creates:
|
||||
- auth:admin permission (Master Admin)
|
||||
@@ -29,10 +65,8 @@ def bootstrap(
|
||||
- Reset token for admin registration
|
||||
- Config (if provided)
|
||||
|
||||
This is the only way to create a new database file.
|
||||
All data is created atomically - if any step fails, nothing is written.
|
||||
|
||||
Args:
|
||||
data: The live root database object (usually a ``DB`` instance).
|
||||
org_name: Display name for the organization (default: "Organization")
|
||||
admin_name: Display name for the admin user (default: "Admin")
|
||||
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
||||
@@ -44,7 +78,7 @@ def bootstrap(
|
||||
"""
|
||||
|
||||
# Check if system is already bootstrapped
|
||||
for p in _ops._db.permissions.values():
|
||||
for p in data.permissions.values():
|
||||
if p.scope == "auth:admin":
|
||||
raise ValueError(
|
||||
"System already bootstrapped (auth:admin permission exists)"
|
||||
@@ -62,7 +96,6 @@ def bootstrap(
|
||||
if reset_expiry is None:
|
||||
reset_expiry = reset_expires()
|
||||
|
||||
with _ops._db.transaction("bootstrap"):
|
||||
# Create auth:admin permission
|
||||
perm_admin = Permission(
|
||||
scope="auth:admin",
|
||||
@@ -70,7 +103,6 @@ def bootstrap(
|
||||
orgs={org_uuid: True}, # Grant to org
|
||||
)
|
||||
perm_admin.uuid = perm_admin_uuid
|
||||
perm_admin.store()
|
||||
|
||||
# Create auth:org:admin permission
|
||||
perm_org_admin = Permission(
|
||||
@@ -79,12 +111,10 @@ def bootstrap(
|
||||
orgs={org_uuid: True}, # Grant to org
|
||||
)
|
||||
perm_org_admin.uuid = perm_org_admin_uuid
|
||||
perm_org_admin.store()
|
||||
|
||||
# Create organization
|
||||
new_org = Org.create(display_name=org_name)
|
||||
new_org.uuid = org_uuid
|
||||
new_org.store()
|
||||
|
||||
# Create Administration role with both permissions
|
||||
admin_role = Role(
|
||||
@@ -93,7 +123,6 @@ def bootstrap(
|
||||
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
||||
)
|
||||
admin_role.uuid = role_uuid
|
||||
admin_role.store()
|
||||
|
||||
# Create admin user
|
||||
admin_user = User(
|
||||
@@ -105,7 +134,6 @@ def bootstrap(
|
||||
theme="",
|
||||
)
|
||||
admin_user.uuid = user_uuid
|
||||
admin_user.store()
|
||||
|
||||
# Create reset token
|
||||
reset_token, reset_passphrase = ResetToken.create(
|
||||
@@ -114,13 +142,20 @@ def bootstrap(
|
||||
token_type="admin bootstrap",
|
||||
passphrase=reset_passphrase,
|
||||
)
|
||||
reset_token.store()
|
||||
|
||||
# Set config if provided
|
||||
if config is not None:
|
||||
_ops._db.config = config
|
||||
data.config = config
|
||||
|
||||
# Generate OIDC signing key
|
||||
_ops._db.oidc.key = secret_key()
|
||||
data.oidc.key = secret_key()
|
||||
|
||||
# Store all bootstrapped objects in the live data object
|
||||
data.permissions[perm_admin_uuid] = perm_admin
|
||||
data.permissions[perm_org_admin_uuid] = perm_org_admin
|
||||
data.orgs[org_uuid] = new_org
|
||||
data.roles[role_uuid] = admin_role
|
||||
data.users[user_uuid] = admin_user
|
||||
data.reset_tokens[reset_token.key] = reset_token
|
||||
|
||||
return reset_passphrase
|
||||
|
||||
@@ -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()}")
|
||||
@@ -1,351 +0,0 @@
|
||||
"""
|
||||
JSONL persistence layer for the database.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import jsondiff
|
||||
import msgspec
|
||||
|
||||
from paskia.db.filelock import LockedFile
|
||||
from paskia.db.logging import log_change
|
||||
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__)
|
||||
|
||||
|
||||
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(Exception):
|
||||
"""Exception raised for database loading errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
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 line_num, raw in enumerate(lines, start=1): # 1-based line numbering
|
||||
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}:{line_num}: {e}")
|
||||
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:
|
||||
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
|
||||
|
||||
This is suitable for reading settings before the server starts.
|
||||
Migrations are applied in-memory only; nothing is queued or flushed.
|
||||
"""
|
||||
path = Path(db_path)
|
||||
if not path.exists():
|
||||
return DB(config=Config(rp_id=rp_id))
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
content = f.read()
|
||||
r = _replay_from_data(content, str(path.resolve()))
|
||||
data_dict = r.state
|
||||
version = r.v
|
||||
except OSError as e:
|
||||
_logger.exception("Failed to load database")
|
||||
raise SystemExit(f"{e}")
|
||||
except (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 data_dict:
|
||||
return DB(config=Config(rp_id=rp_id))
|
||||
|
||||
# Apply migrations in-memory (no persistence)
|
||||
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
||||
|
||||
# Decode to msgspec struct
|
||||
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
||||
return db
|
||||
|
||||
|
||||
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()
|
||||
+124
-19
@@ -2,46 +2,151 @@
|
||||
Database lifecycle: initialization and maintenance.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from kanta import Kanta
|
||||
from kanta.exceptions import DatabaseError
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia import oidc_notify
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.db.structs import DB
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def init(rp_id: str, *args, **kwargs):
|
||||
"""Load database from JSONL file."""
|
||||
if _ops._db._store:
|
||||
_logger.debug("Database already initialized, skipping reload")
|
||||
return
|
||||
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
|
||||
store = JsonlStore(_ops._db, db_path)
|
||||
await store.load(db_path, rp_id=rp_id)
|
||||
_ops._db = store.db
|
||||
_ops._db._store = store
|
||||
# Request a snapshot after successful startup
|
||||
store._snapshot.request_force()
|
||||
runtime = runtime_config()
|
||||
if runtime is None:
|
||||
raise RuntimeError("PASKIA_CONFIG must be defined before importing db.lifecycle")
|
||||
|
||||
kanta = Kanta(
|
||||
str(db_file_path(rp_id=runtime.config.rp_id, create_root=False)),
|
||||
_ops._db,
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = runtime.config.rp_id
|
||||
_ops._db._store = kanta
|
||||
|
||||
|
||||
def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
|
||||
"""Resolve UUID to label from serialized state dict."""
|
||||
if not state:
|
||||
return None
|
||||
|
||||
# Display-name based entities.
|
||||
for bucket in ("users", "orgs", "roles", "permissions"):
|
||||
entity = state.get(bucket, {}).get(uuid_str)
|
||||
if isinstance(entity, dict):
|
||||
display_name = entity.get("display_name")
|
||||
if isinstance(display_name, str) and display_name:
|
||||
return display_name
|
||||
|
||||
# OIDC clients use "name" instead of "display_name".
|
||||
client = state.get("oidc", {}).get("clients", {}).get(uuid_str)
|
||||
if isinstance(client, dict):
|
||||
name = client.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_uuid_label(
|
||||
uuid_str: str,
|
||||
*,
|
||||
previous: dict | None = None,
|
||||
current: dict | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve known entity UUIDs to human-readable labels."""
|
||||
# Prefer previous state so deletions/renames still show a useful label.
|
||||
label = _lookup_uuid_in_state(previous, uuid_str)
|
||||
if label:
|
||||
return label
|
||||
label = _lookup_uuid_in_state(current, uuid_str)
|
||||
if label:
|
||||
return label
|
||||
|
||||
try:
|
||||
uid = UUID(uuid_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if uid in _ops._db.users:
|
||||
return _ops._db.users[uid].display_name
|
||||
if uid in _ops._db.orgs:
|
||||
return _ops._db.orgs[uid].display_name
|
||||
if uid in _ops._db.roles:
|
||||
return _ops._db.roles[uid].display_name
|
||||
if uid in _ops._db.permissions:
|
||||
return _ops._db.permissions[uid].display_name
|
||||
if uid in _ops._db.oidc.clients:
|
||||
return _ops._db.oidc.clients[uid].name
|
||||
return None
|
||||
|
||||
|
||||
@kanta.logfmt
|
||||
def format_log_uuid(
|
||||
value: Any,
|
||||
path: str,
|
||||
previous: Annotated[dict, "pre"] | None = None,
|
||||
current: Annotated[dict, "post"] | None = None,
|
||||
) -> Optional[str]: # noqa: UP045
|
||||
"""Format UUID values/keys/actor labels in transaction logs."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
# Works for transaction actor metadata ($user), values, and path components.
|
||||
return _resolve_uuid_label(value, previous=previous, current=current)
|
||||
|
||||
|
||||
@kanta.fatal_error
|
||||
def terminate(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)
|
||||
|
||||
|
||||
@kanta.bootstrap
|
||||
def bootstrap_db(data: DB) -> None:
|
||||
reset_passphrase = bootstrap(data, config=runtime.config)
|
||||
log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||
|
||||
|
||||
async def init():
|
||||
"""Load database from JSONL file using kanta.
|
||||
|
||||
If the database file is empty, the configured bootstrap callback seeds it
|
||||
with default permissions, organization, role, admin user and a reset token.
|
||||
"""
|
||||
rootpath = Path(kanta.filename).parent
|
||||
try:
|
||||
await asyncio.to_thread(rootpath.mkdir, parents=True, exist_ok=True)
|
||||
await kanta.open()
|
||||
except Exception as e:
|
||||
raise SystemExit(f"{e}") from e
|
||||
|
||||
|
||||
def cleanup_expired() -> int:
|
||||
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||
now = datetime.now(UTC)
|
||||
count = 0
|
||||
limit = now - EXPIRES
|
||||
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
||||
if expired_sessions:
|
||||
oidc_notify.schedule_notifications(expired_sessions)
|
||||
with _ops._db.transaction("expiry"):
|
||||
with kanta.transaction("expiry"):
|
||||
for k in expired_sessions:
|
||||
del _ops._db.sessions[k]
|
||||
count += 1
|
||||
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
|
||||
for k in expired_tokens:
|
||||
del _ops._db.reset_tokens[k]
|
||||
count += 1
|
||||
return count
|
||||
return len(expired_sessions) + len(expired_tokens)
|
||||
|
||||
@@ -14,6 +14,8 @@ import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
from kanta.logging import configure_logging as configure_kanta_logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.db.structs import DB
|
||||
|
||||
@@ -464,3 +466,5 @@ def configure_db_logging() -> None:
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
# Kanta logs changes through its own logger; wire it to the same output.
|
||||
configure_kanta_logging()
|
||||
|
||||
+15
-47
@@ -6,75 +6,43 @@ Each migration should be idempotent and only run when needed.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import msgspec
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.util.crypto import secret_key
|
||||
|
||||
|
||||
class MigrationCtx(msgspec.Struct):
|
||||
"""Context passed to each migration function."""
|
||||
|
||||
rp_id: str
|
||||
|
||||
|
||||
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
|
||||
def migrate_v1(d: dict) -> None:
|
||||
"""Remove Org.created_at fields."""
|
||||
for org_data in d["orgs"].values():
|
||||
org_data.pop("created_at", None)
|
||||
|
||||
|
||||
def migrate_v2(d: dict, ctx: MigrationCtx) -> None:
|
||||
def migrate_v2(d: dict, kanta: Kanta) -> None:
|
||||
"""Add config field if missing."""
|
||||
if "config" not in d:
|
||||
d["config"] = {"rp_id": ctx.rp_id}
|
||||
d["config"] = {"rp_id": kanta.ctx.rp_id}
|
||||
|
||||
|
||||
def migrate_v3(d: dict, ctx: MigrationCtx) -> None:
|
||||
def migrate_v3(d: dict) -> None:
|
||||
"""Ensure all users have visits field."""
|
||||
for user_data in d["users"].values():
|
||||
user_data.setdefault("visits", 0)
|
||||
|
||||
|
||||
def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
|
||||
def migrate_v4(d: dict) -> None:
|
||||
"""OpenID Connect support and hardened session keys."""
|
||||
# Session keys changed to hashes, drop old sessions
|
||||
d["sessions"] = {}
|
||||
# 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(),
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
def migrate_v5(d: dict) -> None:
|
||||
"""Convert config.listen from str to list[str] if needed."""
|
||||
listen = d["config"].get("listen")
|
||||
if listen and isinstance(listen, str):
|
||||
d["config"]["listen"] = [listen]
|
||||
|
||||
+56
-38
@@ -40,6 +40,26 @@ _UNSET = object()
|
||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||
|
||||
|
||||
def _store():
|
||||
"""Return active Kanta instance for the current DB object."""
|
||||
store = _db._store
|
||||
if store is None:
|
||||
raise RuntimeError("Kanta store is not initialized")
|
||||
return store
|
||||
|
||||
|
||||
def _transaction(
|
||||
action: str,
|
||||
ctx: SessionContext | None = None,
|
||||
*,
|
||||
user: str | None = None,
|
||||
mtime: bool | datetime = True,
|
||||
):
|
||||
"""Create a Kanta transaction with minimal metadata mapping."""
|
||||
user_id = str(ctx.user.uuid) if ctx else user
|
||||
return _store().transaction(action, user=user_id, mtime=mtime)
|
||||
|
||||
|
||||
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||
"""Check if a preferred_username is already taken by another user."""
|
||||
if not username:
|
||||
@@ -58,7 +78,7 @@ def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||
|
||||
def update_config(config: Config) -> None:
|
||||
"""Update the stored configuration."""
|
||||
with _db.transaction("update_config"):
|
||||
with _transaction("update_config"):
|
||||
_db.config = config
|
||||
|
||||
|
||||
@@ -66,7 +86,7 @@ def create_permission(perm: Permission, *, ctx: SessionContext | None = None) ->
|
||||
"""Create a new permission."""
|
||||
if perm.uuid in _db.permissions:
|
||||
raise ValueError(f"Permission {perm.uuid} already exists")
|
||||
with _db.transaction("admin:create_permission", ctx):
|
||||
with _transaction("admin:create_permission", ctx):
|
||||
perm.store()
|
||||
|
||||
|
||||
@@ -84,7 +104,7 @@ def update_permission(
|
||||
"""
|
||||
if uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {uuid} not found")
|
||||
with _db.transaction("admin:update_permission", ctx):
|
||||
with _transaction("admin:update_permission", ctx):
|
||||
_db.permissions[uuid].scope = scope
|
||||
_db.permissions[uuid].display_name = display_name
|
||||
_db.permissions[uuid].domain = domain
|
||||
@@ -94,7 +114,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete a permission and remove it from all roles."""
|
||||
if uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {uuid} not found")
|
||||
with _db.transaction("admin:delete_permission", ctx):
|
||||
with _transaction("admin:delete_permission", ctx):
|
||||
_db.permissions[uuid].delete()
|
||||
|
||||
|
||||
@@ -106,7 +126,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||
if org.uuid in _db.orgs:
|
||||
raise ValueError(f"Organization {org.uuid} already exists")
|
||||
now = datetime.now(UTC)
|
||||
with _db.transaction("admin:create_org", ctx):
|
||||
with _transaction("admin:create_org", ctx):
|
||||
new_org = Org.create(display_name=org.display_name, created_at=now)
|
||||
new_org.uuid = org.uuid
|
||||
new_org.store()
|
||||
@@ -138,7 +158,7 @@ def update_org_name(
|
||||
"""Update organization display name."""
|
||||
if uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {uuid} not found")
|
||||
with _db.transaction("admin:update_org_name", ctx):
|
||||
with _transaction("admin:update_org_name", ctx):
|
||||
_db.orgs[uuid].display_name = display_name
|
||||
|
||||
|
||||
@@ -146,7 +166,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete organization and all its roles/users."""
|
||||
if uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {uuid} not found")
|
||||
with _db.transaction("admin:delete_org", ctx):
|
||||
with _transaction("admin:delete_org", ctx):
|
||||
_db.orgs[uuid].delete()
|
||||
|
||||
|
||||
@@ -163,7 +183,7 @@ def add_permission_to_org(
|
||||
if permission_uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
|
||||
with _db.transaction("admin:add_permission_to_org", ctx):
|
||||
with _transaction("admin:add_permission_to_org", ctx):
|
||||
_db.permissions[permission_uuid].orgs[org_uuid] = True
|
||||
|
||||
|
||||
@@ -180,7 +200,7 @@ def remove_permission_from_org(
|
||||
if permission_uuid not in _db.permissions:
|
||||
return # Permission not found, silently return
|
||||
|
||||
with _db.transaction("admin:remove_permission_from_org", ctx):
|
||||
with _transaction("admin:remove_permission_from_org", ctx):
|
||||
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
|
||||
|
||||
|
||||
@@ -190,7 +210,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
||||
raise ValueError(f"Role {role.uuid} already exists")
|
||||
if role.org_uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {role.org_uuid} not found")
|
||||
with _db.transaction("admin:create_role", ctx):
|
||||
with _transaction("admin:create_role", ctx):
|
||||
role.store()
|
||||
|
||||
|
||||
@@ -203,7 +223,7 @@ def update_role_name(
|
||||
"""Update role display name."""
|
||||
if uuid not in _db.roles:
|
||||
raise ValueError(f"Role {uuid} not found")
|
||||
with _db.transaction("admin:update_role_name", ctx):
|
||||
with _transaction("admin:update_role_name", ctx):
|
||||
_db.roles[uuid].display_name = display_name
|
||||
|
||||
|
||||
@@ -218,7 +238,7 @@ def add_permission_to_role(
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
if permission_uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
with _db.transaction("admin:add_permission_to_role", ctx):
|
||||
with _transaction("admin:add_permission_to_role", ctx):
|
||||
_db.roles[role_uuid].permissions[permission_uuid] = True
|
||||
|
||||
|
||||
@@ -231,7 +251,7 @@ def remove_permission_from_role(
|
||||
"""Remove permission from role by UUID."""
|
||||
if role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _db.transaction("admin:remove_permission_from_role", ctx):
|
||||
with _transaction("admin:remove_permission_from_role", ctx):
|
||||
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
|
||||
|
||||
|
||||
@@ -243,7 +263,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
role = _db.roles[uuid]
|
||||
if role.users:
|
||||
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
||||
with _db.transaction("admin:delete_role", ctx):
|
||||
with _transaction("admin:delete_role", ctx):
|
||||
_db.roles[uuid].delete()
|
||||
|
||||
|
||||
@@ -253,7 +273,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
||||
raise ValueError(f"User {new_user.uuid} already exists")
|
||||
if new_user.role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {new_user.role_uuid} not found")
|
||||
with _db.transaction("admin:create_user", ctx):
|
||||
with _transaction("admin:create_user", ctx):
|
||||
new_user.store()
|
||||
|
||||
|
||||
@@ -280,7 +300,7 @@ def update_user_display_name(
|
||||
if not display_name:
|
||||
raise ValueError("Display name cannot be empty")
|
||||
user = _db.users[uuid]
|
||||
with _db.transaction("update_user_display_name", ctx):
|
||||
with _transaction("update_user_display_name", ctx):
|
||||
user.display_name = display_name
|
||||
# Auto-fill preferred_username if not already set
|
||||
if user.preferred_username is None:
|
||||
@@ -354,7 +374,7 @@ def update_user_info(
|
||||
elif len(telephone) > 32:
|
||||
raise ValueError("telephone too long")
|
||||
|
||||
with _db.transaction("update_user_info", ctx):
|
||||
with _transaction("update_user_info", ctx):
|
||||
if display_name is not _UNSET:
|
||||
user.display_name = display_name
|
||||
if theme is not _UNSET:
|
||||
@@ -378,7 +398,7 @@ def update_user_role(
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
if role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _db.transaction("admin:update_user_role", ctx):
|
||||
with _transaction("admin:update_user_role", ctx):
|
||||
_db.users[uuid].role_uuid = role_uuid
|
||||
|
||||
|
||||
@@ -386,7 +406,7 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete user and their credentials/sessions."""
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
with _db.transaction("admin:delete_user", ctx):
|
||||
with _transaction("admin:delete_user", ctx):
|
||||
_db.users[uuid].delete()
|
||||
|
||||
|
||||
@@ -396,7 +416,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
|
||||
raise ValueError(f"Credential {cred.uuid} already exists")
|
||||
if cred.user_uuid not in _db.users:
|
||||
raise ValueError(f"User {cred.user_uuid} not found")
|
||||
with _db.transaction("create_credential", ctx):
|
||||
with _transaction("create_credential", ctx):
|
||||
cred.store()
|
||||
|
||||
|
||||
@@ -410,7 +430,7 @@ def update_credential_sign_count(
|
||||
"""Update credential sign count and last_used."""
|
||||
if uuid not in _db.credentials:
|
||||
raise ValueError(f"Credential {uuid} not found")
|
||||
with _db.transaction("update_credential_sign_count", ctx):
|
||||
with _transaction("update_credential_sign_count", ctx):
|
||||
_db.credentials[uuid].sign_count = sign_count
|
||||
if last_used:
|
||||
_db.credentials[uuid].last_used = last_used
|
||||
@@ -432,12 +452,12 @@ def delete_credential(
|
||||
if user_uuid is not None:
|
||||
if cred.user_uuid != user_uuid:
|
||||
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
||||
with _db.transaction("delete_credential", ctx):
|
||||
with _transaction("delete_credential", ctx):
|
||||
cred.delete()
|
||||
|
||||
|
||||
def update_session(
|
||||
key: bytes,
|
||||
key: str,
|
||||
host: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
@@ -448,7 +468,7 @@ def update_session(
|
||||
"""Update session metadata."""
|
||||
if key not in _db.sessions:
|
||||
raise ValueError("Session not found")
|
||||
with _db.transaction("update_session", ctx):
|
||||
with _transaction("update_session", ctx):
|
||||
s = _db.sessions[key]
|
||||
if host is not None:
|
||||
s.host = host
|
||||
@@ -460,9 +480,7 @@ def update_session(
|
||||
s.validated = validated
|
||||
|
||||
|
||||
def set_session_host(
|
||||
key: bytes, host: str, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Set the host for a session (first-time binding)."""
|
||||
update_session(key, host=host, ctx=ctx)
|
||||
|
||||
@@ -480,7 +498,7 @@ def delete_session(
|
||||
raise ValueError("Session not found")
|
||||
|
||||
oidc_notify.schedule_notifications([key])
|
||||
with _db.transaction(action, ctx):
|
||||
with _transaction(action, ctx):
|
||||
_db.sessions[key].delete()
|
||||
|
||||
|
||||
@@ -499,7 +517,7 @@ def delete_sessions_for_user(
|
||||
|
||||
keys = [s.key for s in user.sessions]
|
||||
oidc_notify.schedule_notifications(keys)
|
||||
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
||||
with _transaction("admin:delete_sessions_for_user", ctx):
|
||||
for sess in user.sessions:
|
||||
sess.delete()
|
||||
|
||||
@@ -530,7 +548,7 @@ def create_reset_token(
|
||||
)
|
||||
if token.key in _db.reset_tokens:
|
||||
raise ValueError("Reset token already exists")
|
||||
with _db.transaction("create_reset_token", ctx, user=user):
|
||||
with _transaction("create_reset_token", ctx, user=user):
|
||||
token.store()
|
||||
return passphrase
|
||||
|
||||
@@ -539,7 +557,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
|
||||
"""Delete a reset token."""
|
||||
if key not in _db.reset_tokens:
|
||||
raise ValueError("Reset token not found")
|
||||
with _db.transaction("delete_reset_token", ctx):
|
||||
with _transaction("delete_reset_token", ctx):
|
||||
_db.reset_tokens[key].delete()
|
||||
|
||||
|
||||
@@ -588,7 +606,7 @@ def login(
|
||||
validated=now,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _db.transaction("login", user=user_str):
|
||||
with _transaction("login", user=user_str):
|
||||
session.store(now)
|
||||
# Update credential
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
@@ -615,7 +633,7 @@ def oidc_login(
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
user_str = str(session.user_uuid)
|
||||
with _db.transaction("oidc_login", user=user_str):
|
||||
with _transaction("oidc_login", user=user_str):
|
||||
session.store(now)
|
||||
# Update credential
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
@@ -661,7 +679,7 @@ def create_credential_session(
|
||||
validated=now,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _db.transaction("create_credential_session", user=user_str):
|
||||
with _transaction("create_credential_session", user=user_str):
|
||||
# Update display name if provided
|
||||
if display_name:
|
||||
_db.users[user_uuid].display_name = display_name
|
||||
@@ -694,7 +712,7 @@ def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> N
|
||||
"""Create a new OIDC client."""
|
||||
if client.uuid in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client.uuid} already exists")
|
||||
with _db.transaction("admin:create_oid_client", ctx):
|
||||
with _transaction("admin:create_oid_client", ctx):
|
||||
_db.oidc.clients[client.uuid] = client
|
||||
|
||||
|
||||
@@ -735,7 +753,7 @@ def update_oid_client(
|
||||
else client.backchannel_logout_uri
|
||||
)
|
||||
|
||||
with _db.transaction("admin:update_oid_client", ctx):
|
||||
with _transaction("admin:update_oid_client", ctx):
|
||||
# Create updated client with new values
|
||||
updated_client = Client(
|
||||
client_secret_hash=secret_hash
|
||||
@@ -761,7 +779,7 @@ def reset_oid_client_secret(
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
client = _db.oidc.clients[client_uuid]
|
||||
with _db.transaction("admin:reset_oid_client_secret", ctx):
|
||||
with _transaction("admin:reset_oid_client_secret", ctx):
|
||||
updated = Client(
|
||||
client_secret_hash=new_secret_hash,
|
||||
name=client.name,
|
||||
@@ -776,5 +794,5 @@ def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -
|
||||
"""Delete an OIDC client."""
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
with _db.transaction("admin:delete_oid_client", ctx):
|
||||
with _transaction("admin:delete_oid_client", ctx):
|
||||
del _db.oidc.clients[client_uuid]
|
||||
|
||||
@@ -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
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
@@ -618,7 +619,7 @@ class Config(msgspec.Struct, omit_defaults=True):
|
||||
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
"""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] = {}
|
||||
orgs: dict[UUID, Org] = {}
|
||||
roles: dict[UUID, Role] = {}
|
||||
@@ -630,8 +631,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
||||
|
||||
def __post_init__(self):
|
||||
# Store reference for persistence (not serialized)
|
||||
self._store = None
|
||||
# Optional store reference for non-global DB instances (e.g. tests).
|
||||
self._store: Any | None = None
|
||||
# Set the key fields on all stored objects
|
||||
for uuid, perm in self.permissions.items():
|
||||
perm.uuid = uuid
|
||||
@@ -651,10 +652,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
for uuid, client in self.oidc.clients.items():
|
||||
client.uuid = uuid
|
||||
|
||||
def transaction(self, action, ctx=None, *, user=None):
|
||||
"""Wrap writes in transaction. Delegates to JsonlStore."""
|
||||
return self._store.transaction(action, ctx, user=user)
|
||||
|
||||
def session_ctx(
|
||||
self, session_secret: str, host: str | None = None
|
||||
) -> SessionContext | None:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from paskia.fastapi.mainapp import app
|
||||
|
||||
__all__ = ["app"]
|
||||
|
||||
@@ -17,6 +17,7 @@ from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import (
|
||||
avatar,
|
||||
permutil,
|
||||
vitedev,
|
||||
)
|
||||
@@ -26,6 +27,7 @@ from paskia.util.apistructs import (
|
||||
ApiOrg,
|
||||
ApiOrgResponse,
|
||||
ApiPermission,
|
||||
ApiUser,
|
||||
)
|
||||
|
||||
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),
|
||||
permissions={p.uuid: p for p in o.permissions},
|
||||
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}
|
||||
|
||||
@@ -28,7 +28,7 @@ def _validate_permission_domain(domain: str | None) -> None:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
rp_id = passkey.instance.rp_id
|
||||
rp_id = passkey.rp_id
|
||||
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||
return
|
||||
raise ValueError(
|
||||
|
||||
@@ -22,7 +22,7 @@ async def admin_get_server_config(
|
||||
):
|
||||
"""Get current server configuration (master admin only)."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
pk = passkey.instance
|
||||
pk = passkey
|
||||
config = db.data().config
|
||||
return {
|
||||
"rp_name": pk.rp_name,
|
||||
@@ -46,7 +46,7 @@ async def admin_update_server_config(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
config = db.data().config
|
||||
pk = passkey.instance
|
||||
pk = passkey
|
||||
|
||||
rp_name = payload.get("rp_name", "").strip() or None
|
||||
auth_host = payload.get("auth_host", "").strip() or None
|
||||
|
||||
@@ -9,7 +9,7 @@ from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
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 (
|
||||
ApiAaguidInfo,
|
||||
ApiCreateLinkResponse,
|
||||
@@ -165,7 +165,7 @@ async def admin_get_user_detail(
|
||||
|
||||
return MsgspecResponse(
|
||||
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},
|
||||
aaguid_info={
|
||||
k: ApiAaguidInfo(**v)
|
||||
|
||||
+88
-3
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
@@ -20,8 +21,18 @@ from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo
|
||||
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
||||
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
|
||||
from paskia.util.apistructs import (
|
||||
ApiCheckUserResponse,
|
||||
ApiOrgContext,
|
||||
ApiRoleContext,
|
||||
ApiSessionContext,
|
||||
ApiSettings,
|
||||
ApiTokenInfo,
|
||||
ApiUserContext,
|
||||
ApiValidateResponse,
|
||||
)
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -46,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
|
||||
_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
def _set_log_extra(request: Request, *parts: str) -> None:
|
||||
values = [part for part in parts if part]
|
||||
if values:
|
||||
request.state.log_extra = " ".join(values)
|
||||
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(_request: Request, exc: ValueError):
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
@@ -100,6 +117,7 @@ async def validate_token(
|
||||
)
|
||||
session.set_session_cookie(response, auth)
|
||||
renewed = True
|
||||
_set_log_extra(request, ctx.session.key)
|
||||
return MsgspecResponse(
|
||||
ApiValidateResponse(
|
||||
valid=True,
|
||||
@@ -109,6 +127,60 @@ async def validate_token(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/check")
|
||||
async def check_user(
|
||||
request: Request,
|
||||
user_uuid: UUID = Query(..., alias="user"),
|
||||
perm: list[str] = Query([]),
|
||||
):
|
||||
"""Check permissions for a user by UUID without requiring a session.
|
||||
|
||||
Query Params:
|
||||
- user: UUID of the user to check.
|
||||
- perm: repeated permission scope the user must possess (ALL required).
|
||||
|
||||
Returns 200 with valid=True/False and the user's effective permissions,
|
||||
scoped to the requesting host (domain-restricted permissions are filtered).
|
||||
Returns 404 if the user UUID does not exist.
|
||||
|
||||
No session cookie is read or written. Caller authentication is not required.
|
||||
"""
|
||||
data = db.data()
|
||||
try:
|
||||
u = data.users[user_uuid]
|
||||
role = u.role
|
||||
org = role.org
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
host = hostutil.normalize_host(request.headers.get("host"))
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
|
||||
effective_perms = []
|
||||
for perm_uuid in role.permission_set:
|
||||
if perm_uuid not in org_perm_uuids:
|
||||
continue
|
||||
try:
|
||||
p = data.permissions[perm_uuid]
|
||||
except KeyError:
|
||||
continue
|
||||
if p.domain is not None and p.domain != host:
|
||||
continue
|
||||
effective_perms.append(p)
|
||||
|
||||
required = " ".join(perm).split()
|
||||
effective_scopes = {p.scope for p in effective_perms}
|
||||
valid = permutil.has_all_scopes(effective_scopes, required)
|
||||
|
||||
ctx = ApiSessionContext(
|
||||
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
|
||||
org=ApiOrgContext(uuid=org.uuid, display_name=org.display_name),
|
||||
role=ApiRoleContext(uuid=role.uuid, display_name=role.display_name),
|
||||
permissions=sorted(effective_scopes),
|
||||
)
|
||||
return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx))
|
||||
|
||||
|
||||
@app.get("/forward")
|
||||
async def forward_authentication(
|
||||
request: Request,
|
||||
@@ -131,6 +203,15 @@ async def forward_authentication(
|
||||
- Otherwise: JSON response with error details and an `iframe` field
|
||||
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
|
||||
"""
|
||||
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
|
||||
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
|
||||
forwarded = (
|
||||
f"{forwarded_method} {forwarded_uri}"
|
||||
if forwarded_method and forwarded_uri
|
||||
else ""
|
||||
)
|
||||
_set_log_extra(request, forwarded)
|
||||
|
||||
try:
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
@@ -138,6 +219,7 @@ async def forward_authentication(
|
||||
host=request.headers.get("host"),
|
||||
max_age=max_age,
|
||||
)
|
||||
_set_log_extra(request, forwarded, ctx.session.key)
|
||||
# Build permission scopes for Remote-Groups header
|
||||
role_permissions = (
|
||||
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
||||
@@ -174,7 +256,7 @@ async def forward_authentication(
|
||||
|
||||
@app.get("/settings")
|
||||
async def get_settings():
|
||||
pk = global_passkey.instance
|
||||
pk = global_passkey
|
||||
base_path = hostutil.ui_base_path()
|
||||
return MsgspecResponse(
|
||||
ApiSettings(
|
||||
@@ -212,6 +294,8 @@ async def api_user_info(
|
||||
clear_session=True,
|
||||
)
|
||||
|
||||
_set_log_extra(request, ctx.session.key)
|
||||
|
||||
return MsgspecResponse(
|
||||
await userinfo.build_user_info(
|
||||
user_uuid=ctx.user.uuid,
|
||||
@@ -287,5 +371,6 @@ async def api_set_session(
|
||||
if not ctx:
|
||||
raise HTTPException(401, f"Session not found on {host}")
|
||||
|
||||
_set_log_extra(request, hash_secret("cookie", secret))
|
||||
session.set_session_cookie(response, secret)
|
||||
return {"status": "ok", "user": str(ctx.user.uuid)}
|
||||
|
||||
+34
-16
@@ -26,8 +26,8 @@ _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||
_PATH = "\033[38;5;250m" # path (white)
|
||||
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
|
||||
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
|
||||
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
|
||||
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
|
||||
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
|
||||
_AUTHZ_USER = "\033[1;34m" # User info (light blue)
|
||||
@@ -112,7 +112,13 @@ def method_color(method: str) -> str:
|
||||
|
||||
|
||||
def format_access_log(
|
||||
client: str, status: int, method: str, host: str, path: str, duration_ms: float
|
||||
client: str,
|
||||
status: int,
|
||||
method: str,
|
||||
host: str,
|
||||
path: str,
|
||||
duration_ms: float,
|
||||
extra: str = "",
|
||||
) -> str:
|
||||
"""Format access log line with colors and aligned fields."""
|
||||
# Format components with fixed widths for alignment
|
||||
@@ -126,8 +132,11 @@ def format_access_log(
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
|
||||
# Format: "IP STATUS METHOD host path TIMING"
|
||||
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
||||
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||
return (
|
||||
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||
)
|
||||
|
||||
|
||||
# WebSocket connection counter (mod 100)
|
||||
@@ -152,20 +161,21 @@ def log_ws_open(ws) -> int:
|
||||
origin = ws.headers.get("origin")
|
||||
|
||||
ip = format_client_ip(client).ljust(19)
|
||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||
# ID right-aligned like status codes (3 chars), emoji formatted like method
|
||||
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
|
||||
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
|
||||
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
|
||||
|
||||
# Determine if origin should be shown (omit when same as host)
|
||||
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
||||
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||
show_origin = origin_host and origin_host != host
|
||||
|
||||
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
||||
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||
|
||||
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
||||
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
|
||||
return ws_id
|
||||
|
||||
|
||||
@@ -191,21 +201,25 @@ WS_CLOSE_CODES = {
|
||||
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
"""Log WebSocket connection close with duration and status."""
|
||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||
# ID right-aligned like status codes (3 chars), "closed" formatted like method
|
||||
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
|
||||
# Pad within the dim color to keep full width in color (8 display chars)
|
||||
closed_str = f"{_TIMING}closed {_RESET}"
|
||||
timing = f"{duration * 1000:.0f}ms"
|
||||
|
||||
# Convert close code to status text
|
||||
if close_code is None:
|
||||
status = "closed"
|
||||
code = "----"
|
||||
status = "unknown"
|
||||
else:
|
||||
code = str(close_code)
|
||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||
|
||||
# 🔌 aligned with status, ID aligned with method
|
||||
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
||||
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
||||
# Status code and text in normal color, not dim
|
||||
status_str = f"{code} {status}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
|
||||
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
||||
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
|
||||
|
||||
|
||||
def log_permission_denied(
|
||||
@@ -244,7 +258,11 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
path = f"{path}?{request.url.query}"
|
||||
status = response.status_code
|
||||
|
||||
line = format_access_log(client, status, method, host, path, duration_ms)
|
||||
extra = getattr(request.state, "log_extra", "")
|
||||
|
||||
line = format_access_log(
|
||||
client, status, method, host, path, duration_ms, extra=extra
|
||||
)
|
||||
logger.info(line)
|
||||
|
||||
return response
|
||||
|
||||
+12
-11
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -7,11 +8,10 @@ import msgspec
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
|
||||
from paskia import authcode, db, globals
|
||||
from paskia.__main__ import DEVMODE
|
||||
from paskia import authcode, db, remoteauth
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.db import start_background, stop_background
|
||||
from paskia.db.background import flush
|
||||
from paskia.db.background import start_background, stop_background
|
||||
from paskia.db.lifecycle import kanta
|
||||
from paskia.db.logging import configure_db_logging
|
||||
from paskia.fastapi import admin, api, auth_host, oid, ws
|
||||
from paskia.fastapi.admin.adminapp import adminapp
|
||||
@@ -21,6 +21,7 @@ from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, passphrase, vitedev
|
||||
from paskia.util.constants import DEVMODE
|
||||
from paskia.util.runtime import RuntimeConfig
|
||||
|
||||
# Configure custom logging
|
||||
@@ -43,13 +44,13 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
"""
|
||||
runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig)
|
||||
|
||||
try:
|
||||
await globals.init(
|
||||
rp_id=runtime.config.rp_id,
|
||||
rp_name=runtime.config.rp_name,
|
||||
origins=runtime.config.origins,
|
||||
bootstrap=False,
|
||||
await asyncio.to_thread(
|
||||
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
||||
)
|
||||
async with kanta:
|
||||
try:
|
||||
await remoteauth.init()
|
||||
await authcode.start()
|
||||
except ValueError as e:
|
||||
logging.error(f"⚠️ {e}")
|
||||
# Re-raise to fail fast
|
||||
@@ -59,7 +60,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
await bootstrap_if_needed(config=runtime.config)
|
||||
if runtime.save:
|
||||
db.update_config(runtime.config)
|
||||
await flush()
|
||||
|
||||
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
||||
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
||||
@@ -126,6 +126,7 @@ async def openid_configuration(request: Request):
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"picture",
|
||||
"groups",
|
||||
"sid",
|
||||
],
|
||||
|
||||
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia.db.structs import Session
|
||||
from paskia.util import oidjwt
|
||||
from paskia.util import avatar, oidjwt
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -361,6 +361,7 @@ def _build_token_response(
|
||||
name=user.display_name,
|
||||
preferred_username=user.preferred_username,
|
||||
email=user.email,
|
||||
picture=avatar.current_avatar_url(user.uuid),
|
||||
groups=groups or None,
|
||||
auth_time=auth_time,
|
||||
)
|
||||
@@ -442,12 +443,15 @@ async def userinfo(
|
||||
|
||||
# Build userinfo response based on scope
|
||||
scope = payload.get("scope", "openid").split()
|
||||
response = {"sub": str(user.uuid)}
|
||||
response: dict[str, object] = {"sub": str(user.uuid)}
|
||||
|
||||
if "profile" in scope:
|
||||
response["name"] = user.display_name
|
||||
if 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:
|
||||
response["email"] = user.email
|
||||
|
||||
@@ -312,7 +312,13 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
|
||||
# Handle authenticate request (no PoW needed - already validated during lookup)
|
||||
if msg.get("authenticate") and request is not None:
|
||||
ctx, secret = await authenticate_and_login(ws, auth)
|
||||
ctx, secret = await authenticate_and_login(
|
||||
ws,
|
||||
auth,
|
||||
session_host=request.host,
|
||||
session_ip=request.ip,
|
||||
session_user_agent=request.user_agent,
|
||||
)
|
||||
|
||||
reset_token = None
|
||||
|
||||
|
||||
+89
-2
@@ -3,11 +3,13 @@ from uuid import UUID
|
||||
from fastapi import (
|
||||
Body,
|
||||
FastAPI,
|
||||
File,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import (
|
||||
@@ -18,12 +20,48 @@ from paskia.authsession import (
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil
|
||||
from paskia.util import avatar, hostutil
|
||||
from paskia.util.apistructs import ApiCreateLinkResponse
|
||||
|
||||
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)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
@@ -103,6 +141,55 @@ async def user_update_info(
|
||||
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")
|
||||
async def user_update_theme(
|
||||
request: Request,
|
||||
|
||||
@@ -58,7 +58,7 @@ async def websocket_register_add(
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError(
|
||||
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
||||
f"The reset link for {passkey.rp_name} is invalid or has expired"
|
||||
)
|
||||
s = get_reset(reset)
|
||||
user_uuid = s.user_uuid
|
||||
|
||||
+36
-16
@@ -23,14 +23,14 @@ async def register_chat(
|
||||
credential_ids: list[bytes] | None = None,
|
||||
):
|
||||
"""Run WebAuthn registration flow and return the verified credential."""
|
||||
options, challenge = passkey.instance.reg_generate_options(
|
||||
options, challenge = passkey.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||
return passkey.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||
|
||||
|
||||
async def authenticate_chat(
|
||||
@@ -43,11 +43,9 @@ async def authenticate_chat(
|
||||
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
|
||||
"""
|
||||
origin = validate_origin(ws)
|
||||
options, challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=credential_ids
|
||||
)
|
||||
options, challenge = passkey.auth_generate_options(credential_ids=credential_ids)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
authcred = passkey.instance.auth_parse(await ws.receive_json())
|
||||
authcred = passkey.auth_parse(await ws.receive_json())
|
||||
|
||||
cred = next(
|
||||
(
|
||||
@@ -58,22 +56,31 @@ async def authenticate_chat(
|
||||
None,
|
||||
)
|
||||
if not cred:
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
raise ValueError(f"This passkey is no longer registered with {passkey.rp_name}")
|
||||
|
||||
verification = passkey.instance.auth_verify(authcred, challenge, cred, origin)
|
||||
verification = passkey.auth_verify(authcred, challenge, cred, origin)
|
||||
return cred, verification.new_sign_count
|
||||
|
||||
|
||||
async def authenticate_and_login(
|
||||
ws: WebSocket,
|
||||
auth: str | None = None,
|
||||
*,
|
||||
session_host: str | None = None,
|
||||
session_ip: str | None = None,
|
||||
session_user_agent: str | None = None,
|
||||
) -> tuple[SessionContext, str]:
|
||||
"""Run WebAuthn authentication flow, create session, and return the session context.
|
||||
|
||||
If auth is provided, restrict authentication to credentials of that session's user.
|
||||
|
||||
Args:
|
||||
ws: The WebSocket connection (used for WebAuthn and origin validation)
|
||||
auth: Existing session cookie for re-auth credential restriction
|
||||
session_host: Override host for the new session (defaults to ws origin)
|
||||
session_ip: Override IP for the new session (defaults to ws client IP)
|
||||
session_user_agent: Override user-agent for the new session (defaults to ws headers)
|
||||
|
||||
Returns:
|
||||
Tuple of (SessionContext for the authenticated session, session secret)
|
||||
"""
|
||||
@@ -83,7 +90,7 @@ async def authenticate_and_login(
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
hostname = normalized_host.split(":")[0]
|
||||
rp_id = passkey.instance.rp_id
|
||||
rp_id = passkey.rp_id
|
||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
metadata = infodict(ws, "auth")
|
||||
@@ -97,18 +104,31 @@ async def authenticate_and_login(
|
||||
|
||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||
|
||||
# Use overrides if provided, otherwise use websocket metadata
|
||||
login_host = (
|
||||
hostutil.normalize_host(session_host)
|
||||
if session_host is not None
|
||||
else normalized_host
|
||||
)
|
||||
if not login_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
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"]
|
||||
)
|
||||
|
||||
# Create session and update user/credential
|
||||
secret = db.login(
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
ip=metadata["ip"],
|
||||
user_agent=metadata["user_agent"],
|
||||
host=login_host,
|
||||
ip=login_ip,
|
||||
user_agent=login_user_agent,
|
||||
)
|
||||
|
||||
# Fetch and return the full session context
|
||||
ctx = session_ctx(secret, host)
|
||||
# Fetch and return the full session context (using the same host the session was created with)
|
||||
ctx = session_ctx(secret, login_host)
|
||||
if not ctx:
|
||||
raise ValueError("Failed to create session context")
|
||||
return ctx, secret
|
||||
|
||||
@@ -96,4 +96,4 @@ def validate_origin(ws: WebSocket) -> str:
|
||||
origin = ws.headers.get("origin")
|
||||
if not origin:
|
||||
raise ValueError("Origin header is required for WebSocket connections")
|
||||
return passkey.instance.validate_origin(origin)
|
||||
return passkey.validate_origin(origin)
|
||||
|
||||
+16
-67
@@ -1,71 +1,20 @@
|
||||
from typing import Generic, TypeVar
|
||||
"""Global Passkey instance configured from PASKIA_CONFIG.
|
||||
|
||||
from paskia import authcode, db, remoteauth
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.sansio import Passkey
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Manager(Generic[T]):
|
||||
"""Generic manager for global instances."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self._instance: T | None = None
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def instance(self) -> T:
|
||||
if self._instance is None:
|
||||
raise RuntimeError(
|
||||
f"{self._name} not initialized. Call globals.init() first."
|
||||
)
|
||||
return self._instance
|
||||
|
||||
@instance.setter
|
||||
def instance(self, instance: T) -> None:
|
||||
self._instance = instance
|
||||
|
||||
|
||||
async def init(
|
||||
rp_id: str = "localhost",
|
||||
rp_name: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
*,
|
||||
bootstrap: bool = True,
|
||||
) -> None:
|
||||
"""Initialize global passkey + database.
|
||||
|
||||
If bootstrap=True (default) the system bootstrap_if_needed() will be invoked.
|
||||
In FastAPI lifespan we call with bootstrap=False to avoid duplicate bootstrapping
|
||||
since the CLI performs it once before servers start.
|
||||
|
||||
Database configuration:
|
||||
Set PASKIA_DB environment variable to specify the JSONL database file path.
|
||||
Default: {rp_id}.paskiadb
|
||||
The Passkey instance is created at import time using the runtime configuration
|
||||
passed via the ``PASKIA_CONFIG`` environment variable. Other runtime setup
|
||||
(remote auth, auth codes, bootstrap checks) is performed explicitly by the
|
||||
FastAPI lifespan once the database is open.
|
||||
"""
|
||||
|
||||
# Initialize passkey instance with provided parameters
|
||||
passkey.instance = Passkey(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name or rp_id,
|
||||
origins=origins,
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import runtime
|
||||
|
||||
runtime = runtime.config()
|
||||
if runtime is None:
|
||||
raise RuntimeError("PASKIA_CONFIG must be defined before importing paskia.globals")
|
||||
|
||||
passkey = Passkey(
|
||||
rp_id=runtime.config.rp_id,
|
||||
rp_name=runtime.config.rp_name,
|
||||
origins=runtime.config.origins,
|
||||
)
|
||||
|
||||
# Initialize database
|
||||
await db.init(rp_id=rp_id)
|
||||
|
||||
# Initialize remote auth manager
|
||||
await remoteauth.init()
|
||||
|
||||
# Initialize auth code manager
|
||||
await authcode.start()
|
||||
|
||||
if bootstrap:
|
||||
# Bootstrap system if needed
|
||||
|
||||
await bootstrap_if_needed()
|
||||
|
||||
|
||||
# Global instances
|
||||
passkey = Manager[Passkey]("Passkey")
|
||||
|
||||
@@ -13,7 +13,7 @@ import httpx
|
||||
|
||||
from paskia import db
|
||||
from paskia.util import oidjwt
|
||||
from paskia.util.runtime import _load_config
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,8 +23,8 @@ _TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||
|
||||
def _issuer() -> str:
|
||||
"""Derive issuer URL from config (same base as discovery document)."""
|
||||
cfg = _load_config()
|
||||
return cfg.get("site_url", "https://localhost")
|
||||
cfg = runtime_config()
|
||||
return cfg.site_url if cfg else "https://localhost"
|
||||
|
||||
|
||||
def _collect_oidc_sessions(
|
||||
|
||||
@@ -24,10 +24,11 @@ class ApiUser(User, kw_only=True):
|
||||
"""User with uuid serialized."""
|
||||
|
||||
uuid: UUID
|
||||
avatar_url: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, u: User) -> ApiUser:
|
||||
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
|
||||
def from_db(cls, u: User, *, avatar_url: str | None = None) -> ApiUser:
|
||||
return cls(uuid=u.uuid, avatar_url=avatar_url, **msgspec.structs.asdict(u))
|
||||
|
||||
|
||||
class ApiOrg(Org, kw_only=True):
|
||||
@@ -139,7 +140,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
|
||||
user: ApiUser
|
||||
credentials: dict[UUID, Credential]
|
||||
aaguid_info: dict[str, ApiAaguidInfo]
|
||||
sessions: dict[bytes, ApiUserSession]
|
||||
sessions: dict[str, ApiUserSession]
|
||||
permissions: dict[UUID, ApiPermission] = {}
|
||||
org: ApiOrg | None = None
|
||||
role: ApiRole | None = None
|
||||
@@ -156,7 +157,7 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
||||
org: ApiOrg
|
||||
permissions: dict[UUID, Permission]
|
||||
roles: dict[UUID, Role]
|
||||
users: dict[UUID, User]
|
||||
users: dict[UUID, ApiUser]
|
||||
|
||||
|
||||
class ApiSettings(msgspec.Struct):
|
||||
@@ -233,6 +234,13 @@ class ApiValidateResponse(msgspec.Struct):
|
||||
ctx: ApiSessionContext
|
||||
|
||||
|
||||
class ApiCheckUserResponse(msgspec.Struct):
|
||||
"""Response struct for check-user endpoint."""
|
||||
|
||||
valid: bool
|
||||
ctx: ApiSessionContext
|
||||
|
||||
|
||||
class ApiAdminInfo(msgspec.Struct, kw_only=True):
|
||||
"""Combined admin info response."""
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Small, dependency-free constants shared by CLI and server modules."""
|
||||
|
||||
import os
|
||||
|
||||
DEFAULT_PORT = 4401
|
||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||
+14
-3
@@ -2,11 +2,12 @@
|
||||
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
|
||||
from paskia.util.runtime import _load_config
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
|
||||
def _cfg():
|
||||
return _load_config()
|
||||
return runtime_config()
|
||||
|
||||
|
||||
def is_root_mode() -> bool:
|
||||
@@ -29,6 +30,16 @@ def ui_base_path() -> str:
|
||||
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:
|
||||
"""Return the base URL for the auth site UI (computed at startup)."""
|
||||
cfg = _cfg()
|
||||
@@ -95,7 +106,7 @@ def normalize_auth_host_and_origins(
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
_load_config.cache_clear()
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
def normalize_host(raw_host: str | None) -> str | None:
|
||||
|
||||
+29
-15
@@ -29,11 +29,14 @@ def _load_or_generate_key() -> None:
|
||||
global _private_key, _public_key, _kid
|
||||
|
||||
data = db.data()
|
||||
store = data._store
|
||||
if store is None:
|
||||
raise RuntimeError("Kanta store is not initialized")
|
||||
if data.oidc.key is not None:
|
||||
_private_key = public_key_from_secret(data.oidc.key)
|
||||
else:
|
||||
raw_key = secret_key()
|
||||
with data.transaction("oidc_key"):
|
||||
with store.transaction("oidc_key"):
|
||||
data.oidc.key = raw_key
|
||||
_private_key = public_key_from_secret(raw_key)
|
||||
|
||||
@@ -78,6 +81,7 @@ def create_id_token(
|
||||
name: str | None = None,
|
||||
preferred_username: str | None = None,
|
||||
email: str | None = None,
|
||||
picture: str | None = None,
|
||||
groups: list[str] | None = None,
|
||||
auth_time: datetime | None = None,
|
||||
expires_in: int = 3600,
|
||||
@@ -93,6 +97,7 @@ def create_id_token(
|
||||
name: User's display name
|
||||
preferred_username: User's preferred username
|
||||
email: User's email address
|
||||
picture: User avatar URL
|
||||
groups: List of permission scopes (groups claim)
|
||||
auth_time: When the user authenticated (last credential use time)
|
||||
expires_in: Token lifetime in seconds
|
||||
@@ -101,8 +106,9 @@ def create_id_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"sub": str(subject),
|
||||
"aud": audience,
|
||||
@@ -119,6 +125,8 @@ def create_id_token(
|
||||
payload["preferred_username"] = preferred_username
|
||||
if email:
|
||||
payload["email"] = email
|
||||
if picture:
|
||||
payload["picture"] = picture
|
||||
if groups:
|
||||
payload["groups"] = groups
|
||||
if auth_time:
|
||||
@@ -147,8 +155,9 @@ def create_access_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"sub": str(subject),
|
||||
"aud": audience,
|
||||
@@ -173,20 +182,24 @@ def decode_access_token(
|
||||
Decoded payload or None if invalid
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _public_key is not None
|
||||
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:
|
||||
decode_kwargs["audience"] = audience
|
||||
else:
|
||||
options["verify_aud"] = False
|
||||
return jwt.decode(
|
||||
token,
|
||||
_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:
|
||||
return None
|
||||
|
||||
@@ -212,8 +225,9 @@ def create_logout_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"iat": int(now.timestamp()),
|
||||
|
||||
@@ -6,7 +6,7 @@ from fnmatch import fnmatchcase
|
||||
from paskia.authsession import session_ctx
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
__all__ = ["has_any", "has_all", "session_context"]
|
||||
__all__ = ["has_any", "has_all", "has_all_scopes", "session_context"]
|
||||
|
||||
|
||||
def _match(perms: set[str], patterns: Sequence[str]):
|
||||
@@ -36,6 +36,11 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
|
||||
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
||||
|
||||
|
||||
def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool:
|
||||
"""Check that a pre-computed scope set satisfies all required patterns."""
|
||||
return all(_match(scopes, patterns)) if patterns else True
|
||||
|
||||
|
||||
async def session_context(auth: str | None, host: str | None = None):
|
||||
if not auth:
|
||||
return None
|
||||
|
||||
+12
-2
@@ -31,9 +31,19 @@ def _load_config() -> "RuntimeConfig | None":
|
||||
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
|
||||
|
||||
|
||||
def config() -> "RuntimeConfig | None":
|
||||
"""Return cached runtime config loaded from PASKIA_CONFIG."""
|
||||
return _load_config()
|
||||
|
||||
|
||||
def clear_config_cache() -> None:
|
||||
"""Clear cached runtime config; next config() call reloads from env."""
|
||||
_load_config.cache_clear()
|
||||
|
||||
|
||||
def update_runtime_config(new_config: Config) -> None:
|
||||
"""Update the runtime configuration with a new Config and refresh the cache."""
|
||||
current_runtime = _load_config()
|
||||
current_runtime = config()
|
||||
if not current_runtime:
|
||||
return # No runtime config to update
|
||||
|
||||
@@ -56,4 +66,4 @@ def update_runtime_config(new_config: Config) -> None:
|
||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode()
|
||||
|
||||
# Clear the cache so next access loads the updated config
|
||||
_load_config.cache_clear()
|
||||
clear_config_cache()
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
from paskia._version import __version__
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import format_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -19,8 +20,8 @@ BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||
|
||||
# ANSI color codes
|
||||
RESET = "\033[0m"
|
||||
YELLOW = "\033[33m" # Dark yellow
|
||||
BRIGHT_YELLOW = "\033[93m" # Bright yellow
|
||||
YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
|
||||
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
|
||||
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
||||
|
||||
|
||||
@@ -50,8 +51,8 @@ def bottom() -> str:
|
||||
def print_startup_config(runtime: RuntimeConfig) -> None:
|
||||
"""Print server configuration on startup."""
|
||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||
y = YELLOW # Dark yellow for main body
|
||||
b = BRIGHT_YELLOW # Bright yellow for highlights/edges
|
||||
y = YELLOW # Bright golden yellow for main body
|
||||
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
|
||||
w = BRIGHT_WHITE # Bold white for URL
|
||||
r = RESET
|
||||
|
||||
@@ -73,16 +74,13 @@ def print_startup_config(runtime: RuntimeConfig) -> None:
|
||||
if runtime.config.auth_host:
|
||||
lines.append(line(f"Auth Host: {runtime.config.auth_host}"))
|
||||
|
||||
from paskia.__main__ import DEFAULT_PORT as P # noqa: PLC0415 - circular
|
||||
from paskia.__main__ import DEVMODE # noqa: PLC0415 - circular
|
||||
|
||||
# Show frontend URL if in dev mode
|
||||
if DEVMODE:
|
||||
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
|
||||
|
||||
# Format listen endpoints (dev mode only uses the first endpoint)
|
||||
|
||||
endpoints = list(parse_endpoints(runtime.config.listen, P))
|
||||
endpoints = list(parse_endpoints(runtime.config.listen, DEFAULT_PORT))
|
||||
if DEVMODE:
|
||||
endpoints = endpoints[:1] # server.run reload=True uses only one
|
||||
parts = [format_endpoint(ep) for ep in endpoints]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from paskia import aaguid, db
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util import hostutil
|
||||
from paskia.util import avatar, hostutil
|
||||
from paskia.util.apistructs import (
|
||||
ApiAaguidInfo,
|
||||
ApiOrg,
|
||||
@@ -56,7 +56,7 @@ async def build_user_info(
|
||||
}
|
||||
|
||||
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},
|
||||
aaguid_info={
|
||||
k: ApiAaguidInfo(**v)
|
||||
|
||||
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"msgspec>=0.20.0",
|
||||
"fastapi-vue>=1.1.0",
|
||||
"ua-parser[regex]>=1.0.1",
|
||||
"kanta>=0.4.0",
|
||||
]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
+77
-18
@@ -12,6 +12,7 @@ in the database to test authenticated endpoints.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
@@ -22,25 +23,38 @@ from uuid import UUID
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from kanta import Kanta
|
||||
|
||||
# Keep runtime initialization invariant aligned with production:
|
||||
# db.lifecycle requires PASKIA_CONFIG at import time.
|
||||
os.environ.setdefault(
|
||||
"PASKIA_CONFIG",
|
||||
json.dumps(
|
||||
{
|
||||
"config": {"rp_id": "localhost", "rp_name": "localhost"},
|
||||
"site_url": "http://localhost:4401",
|
||||
"site_path": "/auth/",
|
||||
"save": False,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import globals as paskia_globals
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import (
|
||||
Config,
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
Role,
|
||||
User,
|
||||
bootstrap,
|
||||
create_credential,
|
||||
create_reset_token,
|
||||
create_role,
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.operations import DB
|
||||
from paskia.db.structs import Session
|
||||
from paskia.fastapi.mainapp import app
|
||||
@@ -59,41 +73,59 @@ def event_loop():
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
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 a kanta bootstrap callback to properly initialize the database with:
|
||||
- auth:admin and auth:org:admin permissions
|
||||
- A default organization with Administration role
|
||||
- An admin user with the Administration role
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||
db = DB(config=Config(rp_id="test.example.com"))
|
||||
store = JsonlStore(db, f.name)
|
||||
db._store = store
|
||||
await store.load()
|
||||
ops_db._db = db
|
||||
ops_db._store = store
|
||||
# Bootstrap creates the initial permissions, org, role, and admin user
|
||||
db = DB()
|
||||
kanta = Kanta(
|
||||
f.name,
|
||||
db,
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = "test.example.com"
|
||||
|
||||
# Register bootstrap callback so kanta seeds the empty DB during open()
|
||||
@kanta.bootstrap(action="bootstrap")
|
||||
def bootstrap_test_db(data: DB) -> None:
|
||||
bootstrap(
|
||||
data,
|
||||
org_name="Test Organization",
|
||||
admin_name="Test Admin",
|
||||
)
|
||||
yield db
|
||||
|
||||
await kanta.open()
|
||||
ops_db._db = db
|
||||
ops_db._db._store = kanta
|
||||
yield ops_db._db
|
||||
await kanta.close()
|
||||
ops_db._db = None
|
||||
ops_db._store = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def passkey_instance() -> Passkey:
|
||||
"""Initialize a passkey instance for testing."""
|
||||
"""Override the module-level passkey instance for testing."""
|
||||
pk = Passkey(
|
||||
rp_id="localhost",
|
||||
rp_name="Test RP",
|
||||
origins=["http://localhost:4401"],
|
||||
)
|
||||
paskia_globals.passkey._instance = pk
|
||||
original = {
|
||||
"rp_id": paskia_globals.passkey.rp_id,
|
||||
"rp_name": paskia_globals.passkey.rp_name,
|
||||
"allowed_origins": paskia_globals.passkey.allowed_origins,
|
||||
}
|
||||
paskia_globals.passkey.rp_id = pk.rp_id
|
||||
paskia_globals.passkey.rp_name = pk.rp_name
|
||||
paskia_globals.passkey.allowed_origins = pk.allowed_origins
|
||||
yield pk
|
||||
paskia_globals.passkey._instance = None
|
||||
paskia_globals.passkey.rp_id = original["rp_id"]
|
||||
paskia_globals.passkey.rp_name = original["rp_name"]
|
||||
paskia_globals.passkey.allowed_origins = original["allowed_origins"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -280,6 +312,33 @@ def create_test_session(
|
||||
)
|
||||
if session.key in ops_db._db.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
with ops_db._db.transaction("create_test_session"):
|
||||
store = ops_db._db._store
|
||||
if store is None:
|
||||
raise RuntimeError("Test DB store is not initialized")
|
||||
with store.transaction("create_test_session"):
|
||||
session.store(now)
|
||||
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 secrets
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
@@ -37,7 +38,7 @@ from paskia.db import (
|
||||
)
|
||||
from paskia.db.operations import DB
|
||||
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 --------------------
|
||||
|
||||
@@ -238,6 +239,38 @@ class TestAdminOrganizations:
|
||||
assert "roles" 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
|
||||
async def test_list_orgs_with_org_admin(
|
||||
self,
|
||||
@@ -902,6 +935,34 @@ class TestAdminUsersInOrg:
|
||||
data = response.json()
|
||||
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
|
||||
async def test_update_user_role_in_org(
|
||||
self,
|
||||
|
||||
+158
-1
@@ -12,6 +12,8 @@ These tests cover:
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -19,8 +21,10 @@ import pytest
|
||||
from paskia import authcode
|
||||
from paskia.authsession import EXPIRES
|
||||
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 tests.conftest import auth_headers, create_test_session
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
|
||||
|
||||
class TestSettingsEndpoint:
|
||||
@@ -46,6 +50,41 @@ class TestSettingsEndpoint:
|
||||
data = response.json()
|
||||
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:
|
||||
"""Tests for POST /auth/api/validate"""
|
||||
@@ -294,6 +333,124 @@ class TestUserInfoEndpoint:
|
||||
data = response.json()
|
||||
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",
|
||||
)
|
||||
store = test_db._store
|
||||
if store is None:
|
||||
raise RuntimeError("Test DB store is not initialized")
|
||||
with store.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:
|
||||
"""Tests for POST /auth/api/set-session"""
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for the CLI entry point in paskia/__main__.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.__main__ import main
|
||||
from paskia.db.structs import DB, Config
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_run(monkeypatch):
|
||||
"""Run the CLI main() with the given args and return the RuntimeConfig."""
|
||||
|
||||
def _run(*args: str, db_root: str | None = None) -> Any:
|
||||
env = os.environ.copy()
|
||||
if db_root is not None:
|
||||
env["PASKIA_DB"] = db_root
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["paskia", *args])
|
||||
monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None)
|
||||
monkeypatch.setattr(
|
||||
"paskia.util.startupbox.print_startup_config", lambda _rt: None
|
||||
)
|
||||
monkeypatch.setattr("logging.basicConfig", lambda **_kw: None)
|
||||
|
||||
clear_config_cache()
|
||||
main()
|
||||
runtime = runtime_config()
|
||||
clear_config_cache()
|
||||
return runtime
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
async def _write_config(db_path: Path, config: Config) -> None:
|
||||
"""Write a Config into a JSONL database file using Kanta.
|
||||
|
||||
The initial root uses a different rp_id so the stored diff includes the
|
||||
target rp_id (required because Config omits defaults when diffing).
|
||||
"""
|
||||
kanta = Kanta(
|
||||
str(db_path),
|
||||
DB(config=Config(rp_id="uninitialized.invalid")),
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = config.rp_id
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:write_config"):
|
||||
kanta.data.config = config
|
||||
await kanta.close()
|
||||
|
||||
|
||||
def write_config(db_path: Path, config: Config) -> None:
|
||||
"""Synchronous wrapper for _write_config."""
|
||||
asyncio.run(_write_config(db_path, config))
|
||||
|
||||
|
||||
def test_cli_defaults(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
runtime = cli_run("--rp-id", "localhost", db_root=tmp)
|
||||
|
||||
assert runtime.config.rp_id == "localhost"
|
||||
assert runtime.config.rp_name is None
|
||||
assert runtime.config.auth_host is None
|
||||
assert runtime.config.origins is None
|
||||
assert runtime.site_url == "http://localhost:4401"
|
||||
assert runtime.site_path == "/auth/"
|
||||
assert runtime.save is False
|
||||
|
||||
|
||||
def test_cli_explicit_options(cli_run):
|
||||
runtime = cli_run(
|
||||
"--rp-id",
|
||||
"example.com",
|
||||
"--rp-name",
|
||||
"Example Corp",
|
||||
"--auth-host",
|
||||
"auth.example.com",
|
||||
"--origin",
|
||||
"https://app.example.com",
|
||||
)
|
||||
|
||||
assert runtime.config.rp_id == "example.com"
|
||||
assert runtime.config.rp_name == "Example Corp"
|
||||
assert runtime.config.auth_host == "https://auth.example.com"
|
||||
assert runtime.config.origins == [
|
||||
"https://auth.example.com",
|
||||
"https://app.example.com",
|
||||
]
|
||||
assert runtime.site_url == "https://auth.example.com"
|
||||
assert runtime.site_path == "/"
|
||||
|
||||
|
||||
def test_cli_loads_stored_config(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "main.db"
|
||||
write_config(
|
||||
db_path,
|
||||
Config(
|
||||
rp_id="example.com",
|
||||
rp_name="Stored Name",
|
||||
origins=["https://stored.example.com"],
|
||||
),
|
||||
)
|
||||
runtime = cli_run("--rp-id", "example.com", db_root=tmp)
|
||||
|
||||
assert runtime.config.rp_name == "Stored Name"
|
||||
assert runtime.config.origins == ["https://stored.example.com"]
|
||||
assert runtime.site_url == "https://stored.example.com"
|
||||
|
||||
|
||||
def test_cli_overrides_stored_config(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "main.db"
|
||||
write_config(db_path, Config(rp_id="example.com", rp_name="Stored Name"))
|
||||
runtime = cli_run(
|
||||
"--rp-id", "example.com", "--rp-name", "Overridden", db_root=tmp
|
||||
)
|
||||
|
||||
assert runtime.config.rp_name == "Overridden"
|
||||
|
||||
|
||||
def test_cli_save_flag(cli_run):
|
||||
runtime = cli_run("--save")
|
||||
assert runtime.save is True
|
||||
|
||||
|
||||
def test_cli_invalid_auth_host(cli_run):
|
||||
with pytest.raises(SystemExit):
|
||||
cli_run("--rp-id", "example.com", "--auth-host", "notsub.example.org")
|
||||
|
||||
|
||||
def test_cli_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "paskia", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Paskia authentication server" in result.stdout
|
||||
+161
-1
@@ -3,16 +3,20 @@ Tests for the user API endpoints (/auth/api/user/).
|
||||
|
||||
These tests cover user self-service operations:
|
||||
- Display name update
|
||||
- Avatar upload/delete
|
||||
- Logout all sessions
|
||||
- Session management (delete specific session)
|
||||
- Credential management (delete credential)
|
||||
- Device addition link creation
|
||||
"""
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
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:
|
||||
@@ -67,6 +71,162 @@ class TestUserDisplayName:
|
||||
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:
|
||||
"""Tests for POST /auth/api/user/logout-all"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user