Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e4f77ba93 | ||
|
|
66c2a9bf07 | ||
|
|
c4360df110 | ||
|
|
c676665795 | ||
|
|
55dd43661c | ||
|
|
753ce868c6 | ||
|
|
3e0152e688 | ||
|
|
c5efa03908 | ||
|
|
0ca4e07e23 | ||
|
|
baa993e586 | ||
|
|
42240dd2c7 | ||
|
|
2ec709905e | ||
|
|
6eb862278f | ||
|
|
dbd697772a | ||
|
|
17abcc48c0 | ||
|
|
97ce10dd6f | ||
|
|
3a7ba09ddd |
@@ -188,11 +188,12 @@ Paste the following and save:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Paskia
|
||||
Description=Paskia authentication system
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=paskia
|
||||
SyslogIdentifier=paskia
|
||||
WorkingDirectory=/srv/paskia
|
||||
ExecStart=uvx paskia@latest
|
||||
|
||||
|
||||
+37
-26
@@ -8,6 +8,17 @@
|
||||
:root {
|
||||
color-scheme: light dark; /* Automatic themes by browser */
|
||||
}
|
||||
.section a, .section button {
|
||||
display: inline-block;
|
||||
padding: 0.4em;
|
||||
margin-right: 0.3em;
|
||||
border: none;
|
||||
background: #aaa2;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,13 +31,14 @@
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<h2>Management Site</h2>
|
||||
<button onclick="window.open('/auth/', '_blank')">👤 User Profile</button>
|
||||
<button onclick="window.open('/auth/admin/', '_blank')">⚙️ Admin Panel</button>
|
||||
<a href="/auth/">👤 User Profile</a>
|
||||
<a href="/auth/admin/">⚙️ Admin Panel</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>API Mode (not leaving the page)</h2>
|
||||
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
|
||||
<button onclick="profileDemo()">👤 Login/Profile</button>
|
||||
<button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
|
||||
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||
@@ -35,10 +47,10 @@
|
||||
|
||||
<div class="section">
|
||||
<h2>Browser Mode (full page)</h2>
|
||||
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx):</p>
|
||||
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button>
|
||||
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx). If not authenticated, you'll see the login page; after auth, a 204 response (blank page = success). Back returns here:</p>
|
||||
<a href="/auth/api/forward">🔐 Basic Auth</a>
|
||||
<a href="/auth/api/forward?max_age=10s">🔄 Reauth (max_age=10s)</a>
|
||||
<a href="/auth/api/forward?perm=auth:admin">🛡️ Admin Only</a>
|
||||
</div>
|
||||
|
||||
<pre id="output">Click a button to test...</pre>
|
||||
@@ -46,52 +58,51 @@
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
|
||||
|
||||
const output = document.getElementById('output');
|
||||
import { apiFetch, apiJson, AuthCancelledError, profile } from '/paskia-js/dist/paskia.js'
|
||||
|
||||
function log(msg) {
|
||||
output.textContent = msg;
|
||||
console.log(msg)
|
||||
document.getElementById('output').textContent = msg
|
||||
}
|
||||
|
||||
// Make an API call using paskia module (handles 401/403 automatically)
|
||||
window.apiCall = async function(url, method = 'GET') {
|
||||
log(`${method} ${url}...`);
|
||||
log(`${method} ${url}...`)
|
||||
try {
|
||||
const response = await apiFetch(url, { method });
|
||||
const response = await apiFetch(url, { method })
|
||||
|
||||
// Forward endpoint returns 204 on success
|
||||
if (response.status === 204) {
|
||||
log('✓ Success (204 No Content)');
|
||||
return;
|
||||
log('✓ Success (204 No Content)')
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log(`Error: ${response.status} ${response.statusText}`);
|
||||
return;
|
||||
log(`Error: ${response.status} ${response.statusText}`)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
log('✓ Response:\n' + JSON.stringify(data, null, 2));
|
||||
const data = await response.json()
|
||||
log('✓ Response:\n' + JSON.stringify(data, null, 2))
|
||||
} catch (e) {
|
||||
if (e instanceof AuthCancelledError) {
|
||||
log('Authentication cancelled');
|
||||
log('Authentication cancelled')
|
||||
} else {
|
||||
log(`Error: ${e.message}`);
|
||||
log(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.logout = async function() {
|
||||
await fetch('/auth/api/logout', { method: 'POST' });
|
||||
log('Logged out');
|
||||
await fetch('/auth/api/logout', { method: 'POST' })
|
||||
log('Logged out')
|
||||
}
|
||||
|
||||
// Browser mode: open the forward endpoint directly in a new window.
|
||||
window.browserNav = function(url) {
|
||||
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
|
||||
window.open(url, '_blank');
|
||||
// Profile dialog: resolves 'login' / 'logout' / 'back'
|
||||
window.profileDemo = async function() {
|
||||
log(`Profile return: ${await profile()}`)
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+15
-1
@@ -2,7 +2,14 @@
|
||||
<div class="app-shell">
|
||||
<StatusMessage />
|
||||
<main class="app-main">
|
||||
<HostProfileView v-if="viewState === 'profile' && isHostMode" />
|
||||
<HostProfileView
|
||||
v-if="viewState === 'profile' && isHostMode"
|
||||
:ctx="store.ctx"
|
||||
:user-info="store.userInfo"
|
||||
:settings="store.settings"
|
||||
@back="goBack"
|
||||
@logout="onHostLogout"
|
||||
/>
|
||||
<ProfileView v-else-if="viewState === 'profile'" />
|
||||
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
|
||||
<AccessDenied v-else-if="viewState === 'terminal'" />
|
||||
@@ -15,6 +22,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import HostProfileView from '@/components/HostProfileView.vue'
|
||||
@@ -48,6 +56,12 @@ const isHostMode = computed(() => {
|
||||
return currentHost !== configuredHost
|
||||
})
|
||||
|
||||
// HostProfileView already posted /auth/api/logout; clear local state and reload.
|
||||
function onHostLogout() {
|
||||
sessionStorage.clear()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function onSessionLost(e) {
|
||||
store.userInfo = null
|
||||
store.ctx = null
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
<template>
|
||||
<template v-if="authMode === 'profile'">
|
||||
<!--
|
||||
Profile mode: render nothing until the session check completes (avoids
|
||||
a load-time flash of the wrong view). Without a session, the login flow
|
||||
runs in place of the profile; on success auth-success is posted and the
|
||||
host resolves profile() with 'login'.
|
||||
-->
|
||||
<RestrictedAuth
|
||||
v-if="profileState === 'login'"
|
||||
mode="login"
|
||||
@authenticated="handleAuthenticated"
|
||||
@back="handleBack"
|
||||
/>
|
||||
<HostProfileView
|
||||
v-else-if="profileState === 'ready'"
|
||||
:ctx="profileCtx"
|
||||
:user-info="profileInfo"
|
||||
:settings="profileSettings"
|
||||
@back="handleBack"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
<div v-else class="view-root profile-pending">
|
||||
<div class="surface surface--tight">
|
||||
<p class="view-lede">{{ profileState === 'error' ? 'Could not load your account.' : 'Loading your account…' }}</p>
|
||||
<div class="button-row">
|
||||
<button type="button" class="btn-secondary" @click="handleBack">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<RestrictedAuth
|
||||
v-else
|
||||
:mode="authMode"
|
||||
:remote-auth-token="remoteAuthToken"
|
||||
:oidc-query-string="oidcQueryString"
|
||||
@@ -11,6 +42,10 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
import HostProfileView from '@/components/HostProfileView.vue'
|
||||
import { fetchJson, settings as paskiaSettings } from 'paskia'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
// Check if this is a remote auth URL: /auth/{token}
|
||||
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
||||
@@ -45,8 +80,32 @@ let authMode
|
||||
if (window.location.pathname === '/auth/restricted/oidc') {
|
||||
authMode = 'oidc'
|
||||
} else {
|
||||
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
|
||||
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
|
||||
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth/profile)
|
||||
authMode = ['reauth', 'forbidden', 'profile'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
|
||||
}
|
||||
|
||||
// Profile mode state: 'loading' | 'login' | 'ready' | 'error'
|
||||
const profileState = ref('loading')
|
||||
const profileCtx = ref(null)
|
||||
const profileInfo = ref(null)
|
||||
const profileSettings = ref(null)
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const [validateData, infoData, settingsData] = await Promise.all([
|
||||
fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||
fetchJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms }),
|
||||
getSettings()
|
||||
])
|
||||
profileCtx.value = validateData.ctx
|
||||
profileInfo.value = infoData
|
||||
profileSettings.value = settingsData
|
||||
updateThemeFromSession(validateData.ctx)
|
||||
profileState.value = 'ready'
|
||||
} catch (error) {
|
||||
// No/expired session: run the login flow in place of the profile
|
||||
profileState.value = error.status === 401 || error.status === 403 ? 'login' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function postToParent(message) {
|
||||
@@ -74,10 +133,18 @@ function handleBack() {
|
||||
})
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
postToParent({
|
||||
type: 'auth-logout'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Check for remote auth token in URL
|
||||
remoteAuthToken.value = extractRemoteToken()
|
||||
|
||||
if (authMode === 'profile') loadProfile()
|
||||
|
||||
postToParent({
|
||||
type: 'auth-ready'
|
||||
})
|
||||
@@ -89,3 +156,15 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-root.profile-pending { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.profile-pending .surface {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
|
||||
<script>{let t=new URLSearchParams(location.hash.slice(1)).get('theme');if(t!=='light'&&t!=='dark')t=localStorage.getItem('paskia-theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
|
||||
<link rel="stylesheet" href="/src/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,92 +1,116 @@
|
||||
<template>
|
||||
<section class="view-root view-root--wide host-view" data-view="host-profile">
|
||||
<header class="view-header">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p class="view-lede">{{ subheading }}</p>
|
||||
</header>
|
||||
<div class="view-root host-profile" data-view="host-profile">
|
||||
<div class="surface surface--tight">
|
||||
<!-- Heading/lede belong to the standalone page; in the dialog the host
|
||||
page already provides the surrounding context. -->
|
||||
<header v-if="!inIframe" class="view-header center">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p class="view-lede">{{ subheading }}</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block" ref="userInfoSection">
|
||||
<div class="section-body">
|
||||
<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"
|
||||
:email="ctx.user.email"
|
||||
:telephone="ctx.user.telephone"
|
||||
:org-display-name="orgDisplayName"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
/>
|
||||
<p v-else class="empty-state">
|
||||
{{ initializing ? 'Loading your account…' : 'No active session found.' }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body host-actions">
|
||||
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="goBack"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="logout"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="authSiteUrl"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="goToAuthSite"
|
||||
>
|
||||
Full Profile
|
||||
</button>
|
||||
<section class="section-block">
|
||||
<div class="section-body">
|
||||
<UserBasicInfo
|
||||
v-if="sessionCtx && info"
|
||||
:name="sessionCtx.user.display_name"
|
||||
:avatar-url="info.user.avatar_url"
|
||||
:visits="info.user.visits"
|
||||
:created-at="info.user.created_at"
|
||||
:last-seen="info.user.last_seen"
|
||||
:email="sessionCtx.user.email"
|
||||
:telephone="sessionCtx.user.telephone"
|
||||
:org-display-name="orgDisplayName"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
/>
|
||||
<p v-else class="empty-state">
|
||||
{{ loading ? 'Loading your account…' : 'No active session found.' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body host-actions">
|
||||
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
v-if="sessionCtx"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="busy"
|
||||
@click="logout"
|
||||
>
|
||||
{{ busy ? 'Signing out…' : 'Logout' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="busy"
|
||||
@click="goToAuthSite"
|
||||
>
|
||||
Full Profile
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!inIframe" class="note"><strong>Logout</strong> from {{ currentHost }}, or view your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { fetchJson, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import { getDirection, navigateButtonRow } from '@/utils/keynav'
|
||||
|
||||
defineProps({
|
||||
initializing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
// Data may be provided by the parent (full-page /auth/ app already loaded it
|
||||
// into the store); otherwise the component fetches it itself (restricted iframe).
|
||||
const props = defineProps({
|
||||
ctx: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
settings: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const emit = defineEmits(['back', 'logout'])
|
||||
|
||||
const inIframe = window.parent !== window
|
||||
const currentHost = window.location.host
|
||||
|
||||
const fetchedCtx = ref(null)
|
||||
const fetchedInfo = ref(null)
|
||||
const fetchedSettings = ref(null)
|
||||
const loading = ref(!(props.ctx && props.userInfo))
|
||||
const busy = ref(false)
|
||||
|
||||
// Template refs for navigation
|
||||
const userInfoSection = ref(null)
|
||||
const buttonRow = ref(null)
|
||||
|
||||
const ctx = computed(() => authStore.userInfo || null)
|
||||
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
|
||||
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
|
||||
const sessionCtx = computed(() => props.ctx || fetchedCtx.value)
|
||||
const info = computed(() => props.userInfo || fetchedInfo.value)
|
||||
const settingsData = computed(() => props.settings || fetchedSettings.value)
|
||||
const orgDisplayName = computed(() => sessionCtx.value?.org?.display_name ?? '')
|
||||
const roleDisplayName = computed(() => sessionCtx.value?.role?.display_name ?? '')
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
const service = authStore.settings?.rp_name
|
||||
const service = settingsData.value?.rp_name
|
||||
return service ? `${service} account` : 'Account overview'
|
||||
})
|
||||
|
||||
@@ -94,11 +118,12 @@ const subheading = computed(() => {
|
||||
return `You're signed in to ${currentHost}.`
|
||||
})
|
||||
|
||||
const authSiteHost = computed(() => authStore.settings?.auth_host || '')
|
||||
const authSiteHost = computed(() => settingsData.value?.auth_host || '')
|
||||
const authSiteUrl = computed(() => {
|
||||
const host = authSiteHost.value
|
||||
if (!host) return ''
|
||||
let path = authStore.settings?.ui_base_path ?? '/auth/'
|
||||
// Fall back to the current host when no separate auth host is configured;
|
||||
// the full profile is at ui_base_path either way.
|
||||
const host = authSiteHost.value || currentHost
|
||||
let path = settingsData.value?.ui_base_path ?? '/auth/'
|
||||
if (!path.startsWith('/')) path = `/${path}`
|
||||
if (!path.endsWith('/')) path = `${path}/`
|
||||
const protocol = window.location.protocol || 'https:'
|
||||
@@ -107,11 +132,27 @@ const authSiteUrl = computed(() => {
|
||||
|
||||
const goToAuthSite = () => {
|
||||
if (!authSiteUrl.value) return
|
||||
window.location.href = authSiteUrl.value
|
||||
// Inside an iframe, open the full profile in a new window and close the
|
||||
// frame (auth-back) so the host page regains focus.
|
||||
if (inIframe) {
|
||||
window.open(authSiteUrl.value, '_blank')
|
||||
emit('back')
|
||||
} else {
|
||||
window.location.href = authSiteUrl.value
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await authStore.logout()
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
}
|
||||
// The parent decides how to react: the full-page app reloads, the iframe
|
||||
// host receives auth-logout and closes the frame.
|
||||
emit('logout')
|
||||
}
|
||||
|
||||
// Keyboard navigation for button row
|
||||
@@ -124,7 +165,39 @@ const handleButtonRowKeydown = (event) => {
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' })
|
||||
}
|
||||
// Up does nothing (no elements above to navigate to)
|
||||
// Down does nothing (no elements below to navigate to)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.settings) {
|
||||
getSettings().then((data) => { fetchedSettings.value = data })
|
||||
}
|
||||
if (props.ctx && props.userInfo) return
|
||||
try {
|
||||
const [validateData, infoData] = await Promise.all([
|
||||
fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||
fetchJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||
])
|
||||
fetchedCtx.value = validateData.ctx
|
||||
fetchedInfo.value = infoData
|
||||
updateThemeFromSession(validateData.ctx)
|
||||
} catch (error) {
|
||||
if (error.status !== 401 && error.status !== 403) {
|
||||
console.error('Failed to load account summary:', error)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-root.host-profile { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.surface.surface--tight {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,9 +38,20 @@ export function initThemeFromCache() {
|
||||
applyTheme(getCachedTheme())
|
||||
}
|
||||
|
||||
/** Theme default from the URL hash (restricted iframe/forward pages only) */
|
||||
function getHashTheme() {
|
||||
const theme = new URLSearchParams(window.location.hash.slice(1)).get('theme')
|
||||
return theme === 'light' || theme === 'dark' ? theme : ''
|
||||
}
|
||||
|
||||
/** Update theme from session context (call after login/session load) */
|
||||
export function updateThemeFromSession(ctx, animate = false) {
|
||||
const theme = ctx?.user?.theme || ''
|
||||
// Always keep the cache in sync with the profile: empty override clears it
|
||||
// so stale values never mask future server-provided themes.
|
||||
setCachedTheme(theme)
|
||||
applyTheme(theme, document.documentElement, animate)
|
||||
// Without a profile override, stay consistent with the initial paint: a
|
||||
// theme parameter on the URL (e.g. host page color scheme injected by
|
||||
// paskia-js) remains in effect before the browser/desktop default.
|
||||
applyTheme(theme || getHashTheme(), document.documentElement, animate)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* - Disables Vite's screen clearing on startup
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
* paths - Array of paths to proxy (default: ['/api'])
|
||||
*/
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
|
||||
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||
const backendUrl = process.env.PASKIA_BACKEND_URL || 'http://localhost:4402'
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "vite-plugin-fastapi-paskia",
|
||||
name: 'vite-plugin-fastapi-paskia',
|
||||
config: () => ({
|
||||
clearScreen: false,
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../paskia/frontend-build",
|
||||
outDir: '../paskia/frontend-build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -64,6 +64,14 @@ export default defineConfig(({ command }) => ({
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'serve-paskia-js',
|
||||
configureServer(server) {
|
||||
// Serve the locally built paskia-js module for the examples page
|
||||
const serve = sirv(resolve(__dirname, '../paskia-js'), { dev: true })
|
||||
server.middlewares.use('/paskia-js', serve)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'serve-examples',
|
||||
configureServer(server) {
|
||||
|
||||
+84
-78
@@ -1,14 +1,14 @@
|
||||
# Paskia
|
||||
|
||||

|
||||
|
||||
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
|
||||
# Paskia
|
||||
|
||||
JavaScript utilities for integrating the [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) into web apps.
|
||||
|
||||
## Installation
|
||||
|
||||
### NPM
|
||||
### npm
|
||||
|
||||
No framework dependencies. Works with any framework (Vue, React, Svelte, etc.) or vanilla JS. Typescript typing included.
|
||||
No framework dependencies. Works with Vue, React, Svelte, vanilla JavaScript and other frontend stacks. TypeScript types are included.
|
||||
|
||||
```sh
|
||||
npm install paskia
|
||||
@@ -20,7 +20,7 @@ import { ... } from 'paskia'
|
||||
|
||||
### Plain JavaScript
|
||||
|
||||
Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) first and host yourself. No Node needed.
|
||||
Import directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) and host it yourself. No Node.js is required.
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
@@ -28,91 +28,102 @@ Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm
|
||||
</script>
|
||||
```
|
||||
|
||||
## Features
|
||||
## Authentication
|
||||
|
||||
### Session Validation
|
||||
### API requests
|
||||
|
||||
Refresh session and track its validity with automatic polling. Pauses on lack of user activity to avoid useless traffic and to allow session expiry even when the page is left open but idle. This monitors that the same account stays logged in but doesn't do any permission checks.
|
||||
`apiFetch` wraps `fetch` with Paskia authentication handling, while `apiJson` adds automatic JSON request/response handling. Both support request timeouts. For the same JSON and timeout handling without prompting the user for authentication, use `fetchJson`.
|
||||
|
||||
```js
|
||||
import { apiJson, apiFetch } from 'paskia'
|
||||
|
||||
const data = await apiJson('/api/endpoint', {
|
||||
method: 'POST',
|
||||
body: { key: 'value' }
|
||||
})
|
||||
|
||||
const response = await apiFetch('/api/endpoint')
|
||||
```
|
||||
|
||||
With `apiJson`, a provided `body` is JSON-encoded with the appropriate content type and the response is parsed as JSON.
|
||||
|
||||
When the server requests authentication, the API call pauses while the appropriate Paskia dialog is shown and retries after successful authentication.
|
||||
|
||||
> Paskia uses `401` and `403` responses to trigger the appropriate **login**, **reauthentication** or **access denied** flow. The backend supplies the authentication URL and context; see the main Paskia documentation for the full response protocol.
|
||||
|
||||
### Account and Profile
|
||||
|
||||
`profile()` provides a single dialog for an application's login/profile button that allows the user to sign in, view who they are and sign out without ever leaving the page.
|
||||
|
||||
```js
|
||||
import { profile } from 'paskia'
|
||||
|
||||
const result = await profile()
|
||||
if (result !== 'back') // Refresh application state
|
||||
```
|
||||
|
||||
When signed out, it presents the login flow and returns `'login'` on success. When signed in, it shows the profile and returns `'logout'` after logout. `'back'` is returned when the dialog is closed without an expected session change.
|
||||
|
||||
Authentication and profile dialogs follow the user's theme override when set in profile, otherwise the host page's light/dark `color-scheme` to remain in the application's color scheme, then the browser/OS preference.
|
||||
|
||||
### Lower-level Authentication
|
||||
|
||||
`apiFetch` and `apiJson` call `showAuthIframe()` internally. Applications using plain `fetch` or `fetchJson` can call it directly with an authentication URL returned by the backend:
|
||||
|
||||
```js
|
||||
import { showAuthIframe } from 'paskia'
|
||||
|
||||
await showAuthIframe(data.auth.iframe)
|
||||
```
|
||||
|
||||
## Session Validation
|
||||
|
||||
`SessionValidator` periodically checks that the active Paskia session is still valid and still belongs to the user your application currently has loaded. Validation also refreshes the session to avoid expiry during use.
|
||||
|
||||
```js
|
||||
import { SessionValidator } from 'paskia'
|
||||
|
||||
const validator = new SessionValidator(
|
||||
() => currentUser?.uuid, // getter for current user ID that we track
|
||||
(error) => handleSessionLost(error) // callback when session is lost
|
||||
() => currentUser?.uuid, // User ID currently known by your app
|
||||
error => handleSessionLost(error)
|
||||
)
|
||||
|
||||
validator.start() // call at your app startup/login
|
||||
validator.stop() // stop the system (optional)
|
||||
validator.start()
|
||||
validator.stop()
|
||||
```
|
||||
|
||||
### API Fetch Utilities
|
||||
The first callback is read on each check, so a logout, expired session or switch to another account invalidates the session your app is currently using. Polling pauses while the user is inactive, avoiding unnecessary traffic and allowing idle sessions to expire.
|
||||
|
||||
Enhanced fetch functions with automatic error handling and authentication retry:
|
||||
## Timeout Settings
|
||||
|
||||
```js
|
||||
import { apiJson, apiFetch } from 'paskia'
|
||||
|
||||
// JSON API calls with automatic auth handling
|
||||
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
|
||||
|
||||
// Raw fetch with auth handling
|
||||
const response = await apiFetch('/api/endpoint')
|
||||
```
|
||||
|
||||
When a 401/403 response includes an auth iframe URL, the request automatically pauses, displays the authentication UI, and retries upon success. In case this is not needed, use standard `fetch` or our `fetchJson`.
|
||||
|
||||
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.
|
||||
Paskia exports mutable defaults for network and session timers:
|
||||
|
||||
```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
|
||||
settings.fetch_ms = 10000 // apiFetch, apiJson and fetchJson timeout
|
||||
settings.auth_ms = 1000 // Session validation request timeout
|
||||
settings.poll_ms = 60000 // Session validation interval
|
||||
settings.idle_ms = 300000 // Inactivity before validation pauses
|
||||
```
|
||||
|
||||
You can still override timeout per request:
|
||||
Request timeout can also be overridden per call:
|
||||
|
||||
```js
|
||||
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
|
||||
await apiJson('/api/upload', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
timeout: 30000
|
||||
})
|
||||
```
|
||||
|
||||
### Authentication Overlay
|
||||
## Shared Blur Backdrop
|
||||
|
||||
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.
|
||||
A shared backdrop provides consistent UX across your application, avoiding different things stacking with their own backdrops and dialogs in unexpected manner.
|
||||
|
||||
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
|
||||
Paskia dialogs use a shared blurred backdrop at z-index `1099` and the authentication iframe at `9999`. Application dialogs can use `1100`–`9998` to appear between them.
|
||||
|
||||
```js
|
||||
import { showAuthIframe, AuthCancelledError } from 'paskia'
|
||||
|
||||
const response = await fetch('/api/protected')
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const data = await response.json()
|
||||
if (data.auth?.iframe) {
|
||||
await showAuthIframe(data.auth.iframe) // Raises AuthCancelledError if the user cancels
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This resolves after the user authenticates (possibly with another account than previously), and you should usually retry the original API request. Note that successful authentication doesn't guarantee that the user still has rights to what originally failed.
|
||||
|
||||
### Shared Blur Backdrop
|
||||
|
||||
The authentication dialog displays with a blur backdrop (z-index 1099). The auth iframe uses z-index 9999. Your app dialogs should use z-index 1100–9998 to appear above the backdrop but below authentication.
|
||||
|
||||
The backdrop is also reusable/refcounted, so you can keep consistent visuals for your own dialogs:
|
||||
The same refcounted backdrop can be used by application UI:
|
||||
|
||||
```js
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
@@ -125,31 +136,26 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
The backdrop only disappears after all holders have released it.
|
||||
It disappears after all holders release it, also avoiding awkward fade/appear animations when changing between multiple dialogs.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### AuthCancelledError (apiFetch, apiJson, showAuthIframe)
|
||||
### `AuthCancelledError`
|
||||
|
||||
If the user clicks Back in the authentication dialog, refusing to authenticate, `AuthCancelledError` is risen (as a response to postMessage from the iframe). The dialog closes as expected and it is up to the app how to continue from there.
|
||||
`apiFetch`, `apiJson` and `showAuthIframe` raise `AuthCancelledError` when the user cancels required authentication with Back or Escape. This means the user does not wish to authenticate, and should not be asked again.
|
||||
|
||||
- Do nothing if the app can continue despite the failed operation (no UI notification needed)
|
||||
- Display a simple Access Denied page with suggestion/button to reload the page to try again
|
||||
Continue without the failed operation when possible, or show an appropriate terminal view when authentication is required to continue.
|
||||
|
||||
Do not retry automatically.
|
||||
|
||||
### UI feedback
|
||||
|
||||
A set of small utilities are available for determining whether the user needs a notification and to format the error message.
|
||||
When the error is a direct result of a user action, we don't want to show an additional message for that, while in other situations we should. Helpers determine whether an error needs user notification and provide a suitable message:
|
||||
|
||||
```js
|
||||
import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia'
|
||||
|
||||
try {
|
||||
await apiJson('/api/action')
|
||||
} catch (e) {
|
||||
if (shouldShowErrorToast(e)) {
|
||||
your.message.display(getUserFriendlyErrorMessage(e))
|
||||
} catch (error) {
|
||||
if (shouldShowErrorToast(error)) {
|
||||
your.message.display(getUserFriendlyErrorMessage(error))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paskia",
|
||||
"version": "1.4.0",
|
||||
"version": "2.1.0",
|
||||
"description": "Paskia authentication utilities for JavaScript",
|
||||
"author": "Leo Vasanko",
|
||||
"license": "Unlicense",
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
isAuthIframeOpen,
|
||||
hideAuthIframe,
|
||||
showAuthIframe,
|
||||
profile,
|
||||
} from './overlay'
|
||||
|
||||
export { SessionValidator } from './validate'
|
||||
|
||||
@@ -32,12 +32,27 @@ body.paskia-backdrop {
|
||||
color-scheme: auto;
|
||||
background: transparent;
|
||||
}
|
||||
#${AUTH_IFRAME_ID}.paskia-dialog {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(36rem, 100%);
|
||||
height: min(42rem, 100%);
|
||||
}
|
||||
`
|
||||
|
||||
type DialogResult = 'login' | 'logout' | 'back'
|
||||
type DialogKind = 'auth' | 'profile'
|
||||
|
||||
let authIframe: HTMLIFrameElement | null = null
|
||||
let authPromise: Promise<void> | null = null
|
||||
let authResolve: (() => void) | null = null
|
||||
let authPromise: Promise<DialogResult | undefined> | null = null
|
||||
let authResolve: ((result?: DialogResult) => void) | null = null
|
||||
let authReject: ((error: Error) => void) | null = null
|
||||
// Auth flows reject AuthCancelledError on auth-back (callers rely on it to
|
||||
// abort request retries) and resolve void on auth-success. The profile dialog
|
||||
// never rejects: auth-back resolves 'back', and auth-success (the user logged
|
||||
// in while the profile dialog was open) resolves 'login'.
|
||||
let dialogKind: DialogKind = 'auth'
|
||||
let messageListenerInstalled = false
|
||||
let backdropHolders = 0
|
||||
|
||||
@@ -89,7 +104,7 @@ function handleAuthMessage(event: MessageEvent): void {
|
||||
case 'auth-success':
|
||||
hideAuthIframe()
|
||||
if (authResolve) {
|
||||
authResolve()
|
||||
authResolve(dialogKind === 'profile' ? 'login' : undefined)
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
@@ -98,8 +113,20 @@ function handleAuthMessage(event: MessageEvent): void {
|
||||
|
||||
case 'auth-back':
|
||||
hideAuthIframe()
|
||||
if (authReject) {
|
||||
if (dialogKind === 'auth' && authReject) {
|
||||
authReject(new AuthCancelledError())
|
||||
} else if (authResolve) {
|
||||
authResolve('back')
|
||||
}
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
break
|
||||
|
||||
case 'auth-logout':
|
||||
hideAuthIframe()
|
||||
if (authResolve) {
|
||||
authResolve('logout')
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
@@ -116,12 +143,15 @@ function ensureMessageListener(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
|
||||
function openIframe(iframeUrl: string, title: string, kind: DialogKind): Promise<DialogResult | undefined> {
|
||||
injectStyles()
|
||||
ensureMessageListener()
|
||||
|
||||
if (authPromise) return authPromise
|
||||
|
||||
dialogKind = kind
|
||||
iframeUrl = withAppTheme(iframeUrl)
|
||||
|
||||
if (document.getElementById(AUTH_IFRAME_ID)) {
|
||||
authPromise = new Promise((resolve, reject) => {
|
||||
authResolve = resolve
|
||||
@@ -140,6 +170,7 @@ export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Pro
|
||||
|
||||
authIframe = document.createElement('iframe')
|
||||
authIframe.id = AUTH_IFRAME_ID
|
||||
if (kind === 'profile') authIframe.classList.add('paskia-dialog')
|
||||
authIframe.title = title
|
||||
authIframe.src = iframeUrl
|
||||
document.body.appendChild(authIframe)
|
||||
@@ -147,6 +178,49 @@ export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Pro
|
||||
return authPromise
|
||||
}
|
||||
|
||||
// Detect the host page's own color scheme (CSS color-scheme on body) as an
|
||||
// implicit app-level default. Only an unambiguous 'light' or 'dark' counts;
|
||||
// 'normal', 'light dark' etc. mean the page adapts, so no override is needed.
|
||||
function detectColorScheme(): string {
|
||||
if (typeof window === 'undefined' || !document.body) return ''
|
||||
const scheme = getComputedStyle(document.body).colorScheme
|
||||
return scheme === 'light' || scheme === 'dark' ? scheme : ''
|
||||
}
|
||||
|
||||
// Apply the host page's own color scheme to the iframe URL hash — only when
|
||||
// the URL has no theme parameter yet (a server-provided user theme override
|
||||
// is authoritative). The restricted UI's precedence is: URL parameter (user
|
||||
// override from the server, else host color scheme) > cached profile theme
|
||||
// (localStorage) > browser/desktop default.
|
||||
function withAppTheme(iframeUrl: string): string {
|
||||
const theme = detectColorScheme()
|
||||
if (!theme) return iframeUrl
|
||||
const hashIndex = iframeUrl.indexOf('#')
|
||||
const base = hashIndex === -1 ? iframeUrl : iframeUrl.slice(0, hashIndex)
|
||||
const params = new URLSearchParams(hashIndex === -1 ? '' : iframeUrl.slice(hashIndex + 1))
|
||||
if (params.has('theme')) return iframeUrl
|
||||
params.set('theme', theme)
|
||||
return `${base}#${params}`
|
||||
}
|
||||
|
||||
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
|
||||
return openIframe(iframeUrl, title, 'auth').then(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the minimal profile of the logged-in user in a compact dialog iframe.
|
||||
*
|
||||
* Unlike the auth flows, this always resolves — 'login' when the user was
|
||||
* signed out and completed the login flow inside the frame, 'logout' when
|
||||
* they signed out inside the frame, 'back' when they closed it otherwise.
|
||||
* The caller decides from context how to react to each (e.g. whether to
|
||||
* start a new login attempt with showAuthIframe).
|
||||
*/
|
||||
export function profile(): Promise<DialogResult> {
|
||||
return openIframe('/auth/restricted/iframe#mode=profile', 'Profile', 'profile')
|
||||
.then((result) => result ?? 'back')
|
||||
}
|
||||
|
||||
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
|
||||
injectStyles()
|
||||
const existing = document.getElementById(AUTH_IFRAME_ID)
|
||||
|
||||
+14
-11
@@ -5,8 +5,8 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue import env, server, teleport
|
||||
from fastapi_vue.logging import setup_logging
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.db import legacy
|
||||
@@ -17,8 +17,12 @@ from paskia.domains import build as build_registry
|
||||
from paskia.domains import configure as configure_domains
|
||||
from paskia.domains import validate_config
|
||||
from paskia.util import hostutil, startupbox
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.runtime import ServeConfig
|
||||
from paskia.util.runtime import serve_config
|
||||
|
||||
# Keep the literal value here: fastapi-vue-setup reads DEFAULT_PORT from
|
||||
# this module on upgrades. The app-side shared copy is paskia.util.constants.
|
||||
DEFAULT_PORT = 4401
|
||||
os.environ["FASTAPI_VUE"] = "PASKIA"
|
||||
|
||||
EPILOG = """\
|
||||
Examples:
|
||||
@@ -226,11 +230,10 @@ def cmd_serve(args: argparse.Namespace) -> None:
|
||||
# is the admin's job via the admin interface) are logged by build().
|
||||
|
||||
# Pass process-global serve parameters to the server process(es)
|
||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(
|
||||
ServeConfig(listen=listen)
|
||||
).decode()
|
||||
serve_config().listen = listen
|
||||
teleport() # Serialize bound config before spawning workers
|
||||
|
||||
startupbox.print_startup_config(registry, listen=listen)
|
||||
startupbox.print_startup_config(registry, listen=listen, default_port=DEFAULT_PORT)
|
||||
|
||||
# Run the server (spawns processes in dev mode)
|
||||
# tracerite, access logging and log config are handled by fastapi_vue.server;
|
||||
@@ -241,13 +244,13 @@ def cmd_serve(args: argparse.Namespace) -> None:
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
startup_box=None,
|
||||
reload=Path(__file__).parent if DEVMODE else False,
|
||||
reload=Path(__file__).parent if env.dev else False,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Configure logging to remove the "ERROR:root:" prefix
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
# Full logging setup (tracerite, formatting) before any CLI output
|
||||
setup_logging()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="paskia",
|
||||
|
||||
+3
-2
@@ -128,9 +128,10 @@ def _legacy_to_db(old: LegacyDB) -> DB:
|
||||
origins[origin_key(origin)] = True
|
||||
if old.config.auth_host:
|
||||
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
|
||||
if not origins:
|
||||
if not old.config.origins:
|
||||
# Legacy semantics: no origins configured = the whole rp-id domain
|
||||
# allowed. The new format requires explicit entries.
|
||||
# allowed, regardless of a dedicated auth host. The new format
|
||||
# requires explicit entries.
|
||||
origins[f"**.{rp_id}"] = True
|
||||
|
||||
new_config = Config(
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from kanta.logging import configure_logging as configure_kanta_logging
|
||||
from fastapi_vue import env
|
||||
|
||||
from paskia import authcode, db, domains, remoteauth
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
@@ -19,12 +19,8 @@ from paskia.fastapi.dispatch import DispatchMiddleware
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import passphrase, vitedev
|
||||
from paskia.util.constants import DEVMODE
|
||||
from paskia.util.runtime import serve_config
|
||||
|
||||
# Configure custom logging
|
||||
configure_kanta_logging()
|
||||
|
||||
# Path to examples/index.html when running from source tree
|
||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
|
||||
@@ -39,7 +35,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
Domain configuration is read from the database.
|
||||
"""
|
||||
cfg = serve_config()
|
||||
domains.configure(listen=cfg.listen if cfg else None)
|
||||
domains.configure(listen=cfg.listen)
|
||||
|
||||
await asyncio.to_thread(
|
||||
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
||||
@@ -68,7 +64,7 @@ app = FastAPI(
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
debug=DEVMODE,
|
||||
debug=env.dev,
|
||||
)
|
||||
|
||||
# WebSocket and HTTP access logging is handled by fastapi_vue's ASGI middleware;
|
||||
|
||||
@@ -15,6 +15,7 @@ from uuid import UUID
|
||||
|
||||
import base64url
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from uarite import uaparse
|
||||
|
||||
from paskia import authcode, db, remoteauth
|
||||
from paskia.authcode import CookieCode
|
||||
@@ -23,7 +24,7 @@ from paskia.domains import current_domain, registry
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_and_login
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.util import pow, useragent
|
||||
from paskia.util import pow
|
||||
|
||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -458,9 +459,7 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
if requesting_domain
|
||||
else request.rp_id
|
||||
),
|
||||
"user_agent_pretty": useragent.compact_user_agent(
|
||||
request.user_agent
|
||||
),
|
||||
"user_agent_pretty": uaparse(request.user_agent).pretty,
|
||||
"client_ip": request.ip,
|
||||
"action": request.action,
|
||||
"pow": {
|
||||
|
||||
@@ -11,10 +11,10 @@ from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
from uarite import uaparse
|
||||
|
||||
from paskia import db
|
||||
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
|
||||
from paskia.util import useragent
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API structs - inherit from db structs, add uuid for serialization
|
||||
@@ -124,7 +124,7 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True):
|
||||
credential_uuid=s.credential_uuid,
|
||||
host=s.host,
|
||||
ip=s.ip,
|
||||
user_agent=useragent.compact_user_agent(s.user_agent),
|
||||
user_agent=uaparse(s.user_agent).pretty,
|
||||
validated=s.validated,
|
||||
last_renewed=s.validated,
|
||||
is_current=s.key == current_key,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Small, dependency-free constants shared by CLI and server modules."""
|
||||
|
||||
import os
|
||||
|
||||
# App-side default; paskia.__main__ keeps its own literal copy because
|
||||
# fastapi-vue-setup reads DEFAULT_PORT from the CLI module on upgrades.
|
||||
DEFAULT_PORT = 4401
|
||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||
|
||||
@@ -98,15 +98,3 @@ def normalize_host(raw_host: str | None) -> str | None:
|
||||
# Strip port from host:port
|
||||
netloc = netloc.rsplit(":", 1)[0]
|
||||
return netloc.lower().rstrip(".") or None
|
||||
|
||||
|
||||
def format_endpoint(ep: dict) -> str:
|
||||
"""Format an endpoint dict to a listen string (e.g. 'unix:/path' or 'host:port')."""
|
||||
if uds := ep.get("uds"):
|
||||
return f"unix:{uds}"
|
||||
host = ep["host"]
|
||||
port = ep["port"]
|
||||
# Bracket IPv6 addresses
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
return f"{host}:{port}"
|
||||
|
||||
+9
-18
@@ -2,14 +2,13 @@
|
||||
|
||||
Domain configuration lives in the database (``Config.domains``); the
|
||||
``PASKIA_CONFIG`` environment variable only carries the effective listen
|
||||
endpoints so that child processes (uvicorn reload / workers) can derive
|
||||
site URLs the same way the parent did.
|
||||
endpoints so that child processes (uvicorn reload / workers) derive site
|
||||
URLs the same way the parent did. The CLI entry point mutates the bound
|
||||
object before ``server.run()`` calls ``teleport()`` to pass it on.
|
||||
"""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import env
|
||||
|
||||
|
||||
class ServeConfig(msgspec.Struct):
|
||||
@@ -18,19 +17,11 @@ class ServeConfig(msgspec.Struct):
|
||||
listen: list[str] | None = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load() -> ServeConfig | None:
|
||||
raw = os.getenv("PASKIA_CONFIG")
|
||||
if not raw:
|
||||
return None
|
||||
return msgspec.json.decode(raw.encode(), type=ServeConfig)
|
||||
|
||||
|
||||
def serve_config() -> ServeConfig | None:
|
||||
"""Return cached serve configuration loaded from PASKIA_CONFIG."""
|
||||
return _load()
|
||||
def serve_config() -> ServeConfig:
|
||||
"""Return the serve configuration bound to PASKIA_CONFIG."""
|
||||
return env(ServeConfig, name="CONFIG")
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Clear cached serve configuration; next serve_config() reloads."""
|
||||
_load.cache_clear()
|
||||
"""Drop the bound configuration; next serve_config() re-decodes."""
|
||||
env._bindings.pop("CONFIG", None) # noqa: SLF001
|
||||
|
||||
+65
-60
@@ -2,22 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from sys import stderr
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi_vue import env
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
from fastapi_vue.server import print_startup_box
|
||||
|
||||
from paskia._version import __version__
|
||||
from paskia.domains import auth_host_url, origin_url, partition_origins
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import format_endpoint, wildcard_base
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.hostutil import wildcard_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.domains import DomainRegistry
|
||||
|
||||
BOX_WIDTH = 80 # Maximum inner width (excluding box chars)
|
||||
URL_COL = 22 # Column where header URLs start (past the logo graphic)
|
||||
|
||||
# ANSI color codes
|
||||
@@ -26,46 +25,12 @@ 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
|
||||
|
||||
_TOKENS = re.compile(r"\033\[[0-9;]*m|.")
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Calculate visible length of text, ignoring ANSI escape codes."""
|
||||
return len(re.sub(r"\033\[[0-9;]*m", "", text))
|
||||
|
||||
|
||||
def _truncate(text: str, width: int) -> str:
|
||||
"""Cut text to at most `width` visible chars, keeping ANSI codes intact."""
|
||||
if _visible_len(text) <= width:
|
||||
return text
|
||||
out = []
|
||||
visible = 0
|
||||
for tok in _TOKENS.findall(text):
|
||||
if tok.startswith("\033"):
|
||||
out.append(tok)
|
||||
elif visible < width - 1:
|
||||
out.append(tok)
|
||||
visible += 1
|
||||
else:
|
||||
break
|
||||
return "".join(out) + "…" + RESET
|
||||
|
||||
|
||||
def line(text: str = "", width: int = BOX_WIDTH) -> str:
|
||||
"""Format a line inside the box with proper padding, truncating if needed."""
|
||||
text = _truncate(text, width)
|
||||
padding = width - _visible_len(text)
|
||||
return f"┃ {text}{' ' * padding} ┃\n"
|
||||
|
||||
|
||||
def top(width: int = BOX_WIDTH) -> str:
|
||||
return "┏" + "━" * (width + 2) + "┓\n"
|
||||
|
||||
|
||||
def bottom(width: int = BOX_WIDTH) -> str:
|
||||
return "┗" + "━" * (width + 2) + "┛\n"
|
||||
|
||||
|
||||
def _compact_url(url: str) -> str:
|
||||
"""Bare host for https URLs; scheme and port kept for plain http."""
|
||||
stripped = url.removeprefix("https://")
|
||||
@@ -85,9 +50,47 @@ def _origin_phrase(key: str, rp_id: str) -> str:
|
||||
return _compact_url(origin_url(key))
|
||||
|
||||
|
||||
def _covered_by_wildcard(key: str, pattern: str) -> bool:
|
||||
"""Whether an origins-table key is redundant given a wildcard key.
|
||||
|
||||
Mirrors DomainConfig matching (sansio._allowlisted): a wildcard covers
|
||||
hostnames under its base over https (any port), except under localhost
|
||||
where any scheme and any port match. Plain http entries outside
|
||||
localhost are therefore never covered and stay listed.
|
||||
"""
|
||||
base = wildcard_base(pattern)
|
||||
if base is None:
|
||||
return False
|
||||
# Keys are bare hosts (https:// and '/' stripped by origin_key, port
|
||||
# kept) or full origins; urlparse needs a scheme or '//' prefix.
|
||||
hostname = urlparse(key if "://" in key else f"//{key}").hostname
|
||||
if not hostname:
|
||||
return False
|
||||
if pattern.startswith("**."):
|
||||
matched = hostutil.is_subdomain(hostname, base)
|
||||
else:
|
||||
# '*.base' covers exactly one subdomain level
|
||||
matched = hostname.endswith(f".{base}") and "." not in hostname[
|
||||
: -len(base) - 1
|
||||
]
|
||||
if not matched:
|
||||
return False
|
||||
if hostutil.is_subdomain(base, "localhost"):
|
||||
return True # localhost: any scheme, any port
|
||||
return "://" not in key or key.startswith("https://")
|
||||
|
||||
|
||||
def _signin_summary(in_domain: list[str], rp_id: str) -> str:
|
||||
"""Compact summary of a domain's in-domain sign-in sites."""
|
||||
phrases = [_origin_phrase(key, rp_id) for key in sorted(in_domain)]
|
||||
# Prune entries already covered by a reported wildcard (e.g. the auth
|
||||
# host under '**.{rp-id}'); http origins outside localhost survive.
|
||||
wildcards = [k for k in in_domain if wildcard_base(k)]
|
||||
keys = [
|
||||
k
|
||||
for k in in_domain
|
||||
if wildcard_base(k) or not any(_covered_by_wildcard(k, w) for w in wildcards)
|
||||
]
|
||||
phrases = [_origin_phrase(key, rp_id) for key in sorted(keys)]
|
||||
if len(phrases) > 2:
|
||||
n = len(phrases) - 1
|
||||
return f"{phrases[0]}, +{n} site{'s' if n > 1 else ''}"
|
||||
@@ -95,7 +98,10 @@ def _signin_summary(in_domain: list[str], rp_id: str) -> str:
|
||||
|
||||
|
||||
def print_startup_config(
|
||||
registry: DomainRegistry, listen: list[str] | None = None
|
||||
registry: DomainRegistry,
|
||||
listen: list[str] | None = None,
|
||||
*,
|
||||
default_port: int,
|
||||
) -> None:
|
||||
"""Print server configuration on startup (one section per domain)."""
|
||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||
@@ -106,18 +112,15 @@ def print_startup_config(
|
||||
|
||||
domains = sorted(registry.domains, key=lambda d: d.rp_id)
|
||||
|
||||
# Format listen endpoints (dev mode only uses the first endpoint)
|
||||
endpoints = list(parse_endpoints(listen, DEFAULT_PORT))
|
||||
if DEVMODE:
|
||||
endpoints = endpoints[:1] # server.run reload=True uses only one
|
||||
parts = [format_endpoint(ep) for ep in endpoints]
|
||||
# Endpoints as bound, passed to fastapi_vue for the {listen} field
|
||||
endpoints = list(parse_endpoints(listen, default_port))
|
||||
|
||||
# Header URLs: when a vite dev server is configured, its URL (marked
|
||||
# "vite dev"); otherwise one per configured auth host (a full origin URL,
|
||||
# clickable in terminals). If none are configured, guess one domain
|
||||
# (prefer the shortest https rp_id) and link its /auth/ site path.
|
||||
# Entries are pre-styled: bold for the URL, plain for any marker.
|
||||
vite_url = os.environ.get("PASKIA_VITE_URL") if DEVMODE else None
|
||||
vite_url = env.vite_url if env.dev else None
|
||||
if vite_url:
|
||||
header_urls = [f"{w}{vite_url}{r} (vite dev)"]
|
||||
else:
|
||||
@@ -138,9 +141,10 @@ def print_startup_config(
|
||||
rows = []
|
||||
# Logo lines 4-5 carry the first two header URLs; further URLs go on
|
||||
# blank-gutter lines beneath the graphic, all at the same column.
|
||||
# @VERSION@/@LISTEN@ are filled in by fastapi_vue's print_startup_box.
|
||||
logo = [
|
||||
f" {b}▄▄▄▄▄{r}",
|
||||
f"{b}█{y} {b}█{r} Paskia {__version__} @ {' '.join(parts)}",
|
||||
f"{b}█{y} {b}█{r} Paskia @VERSION@ @ @LISTEN@",
|
||||
f"{b}█{y} {b}█{y}▄▄▄▄▄▄▄▄▄▄▄▄{r}",
|
||||
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r}",
|
||||
f" {y}▀▀▀▀▀{r}",
|
||||
@@ -156,7 +160,7 @@ def print_startup_config(
|
||||
rows.append(f"{' ' * URL_COL}{url}")
|
||||
|
||||
for domain in domains:
|
||||
# One compact line per domain; overlong lines are capped at render.
|
||||
# One compact line per domain.
|
||||
rp_name = domain.rp_name
|
||||
suffix = f" ({rp_name})" if rp_name and rp_name != domain.rp_id else ""
|
||||
head = f"{w}{domain.rp_id}{r}{suffix}"
|
||||
@@ -164,21 +168,22 @@ def print_startup_config(
|
||||
rows.append(f"{head} — no sign-in sites")
|
||||
continue
|
||||
in_domain, related = partition_origins(domain.rp_id, domain.config.origins)
|
||||
parts = []
|
||||
phrases = []
|
||||
if in_domain:
|
||||
parts.append(_signin_summary(in_domain, domain.rp_id))
|
||||
parts.extend(_compact_url(origin_url(k)) for k in sorted(related))
|
||||
phrases.append(_signin_summary(in_domain, domain.rp_id))
|
||||
phrases.extend(_compact_url(origin_url(k)) for k in sorted(related))
|
||||
# "with" implies the rp_id itself may sign in (exact key or a full
|
||||
# wildcard); otherwise the origins are a mere list, after a colon.
|
||||
covers_self = any(
|
||||
k == domain.rp_id or k == f"**.{domain.rp_id}" for k in in_domain
|
||||
)
|
||||
sep = " with " if covers_self else ": "
|
||||
rows.append(f"{head}{sep}{' and '.join(parts)}")
|
||||
rows.append(f"{head}{sep}{' and '.join(phrases)}")
|
||||
|
||||
# Size the box to the widest row, capped at BOX_WIDTH.
|
||||
width = min(BOX_WIDTH, max(_visible_len(t) for t in rows))
|
||||
out = [top(width)]
|
||||
out.extend(line(text, width) for text in rows)
|
||||
out.append(bottom(width))
|
||||
stderr.write("".join(out))
|
||||
# fastapi_vue prints the box: version from package metadata, listen
|
||||
# addresses as bound (localhost expanded to both loopbacks). Braces in
|
||||
# our content (e.g. an rp-name) are escaped before template formatting.
|
||||
text = "\n".join(rows)
|
||||
text = text.replace("{", "{{").replace("}", "}}")
|
||||
text = text.replace("@VERSION@", "{version}").replace("@LISTEN@", "{listen}")
|
||||
print_startup_box(text, "paskia.fastapi.mainapp:app", endpoints)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
from ua_parser import parse
|
||||
|
||||
|
||||
def compact_user_agent(ua: str | None) -> str:
|
||||
"""Format user agent string into a compact display format.
|
||||
|
||||
Returns empty string for empty/missing user agents.
|
||||
Returns original UA for unrecognized ones.
|
||||
"""
|
||||
if not ua or not ua.strip() or ua == "-":
|
||||
return ""
|
||||
r = parse(ua)
|
||||
browser = r.user_agent.family if r.user_agent else None
|
||||
ver = r.user_agent.major if r.user_agent else ""
|
||||
os_name = r.os.family if r.os else None
|
||||
dev = r.device.family if r.device else None
|
||||
# If browser is unrecognized, return original UA
|
||||
if browser in (None, "Other") and os_name in (None, "Other"):
|
||||
return ua
|
||||
# Filter out "Other" values
|
||||
browser = browser if browser and browser != "Other" else ""
|
||||
os_name = os_name if os_name and os_name != "Other" else ""
|
||||
# Exclude device if it's "Other" or matches browser family (parser bug)
|
||||
if dev in (None, "Other") or dev == browser:
|
||||
dev = ""
|
||||
# Build compact string, filtering empty parts
|
||||
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
|
||||
result = " ".join(p for p in parts if p).strip()
|
||||
return result
|
||||
+3
-3
@@ -21,9 +21,9 @@ dependencies = [
|
||||
"pyjwt[crypto]>=2.11.0",
|
||||
"jsondiff>=2.2.1",
|
||||
"msgspec>=0.20.0",
|
||||
"fastapi-vue~=1.4.2",
|
||||
"ua-parser[regex]>=1.0.1",
|
||||
"kanta>=0.7.0",
|
||||
"fastapi-vue~=1.7.1",
|
||||
"kanta>=0.9.2",
|
||||
"uarite>=0.2.1",
|
||||
]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
+68
-58
@@ -10,6 +10,7 @@ import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import tracerite
|
||||
@@ -68,10 +69,13 @@ def build_caddyfile(origins: list[str], viteurl: str, backurl: str) -> str:
|
||||
return "\n".join(caddyfile_parts)
|
||||
|
||||
|
||||
async def run_caddy(
|
||||
origins: list[str], viteurl: str, backurl: str
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Start Caddy as HTTPS reverse proxy, wait for ready signal."""
|
||||
async def run_caddy(origins: list[str], viteurl: str, backurl: str) -> None:
|
||||
"""Run Caddy as HTTPS reverse proxy for the group's lifetime.
|
||||
|
||||
Waits for the ready signal, then drains stderr until Caddy exits or the
|
||||
task is cancelled (ProcessGroup shutdown), terminating Caddy on exit.
|
||||
Raises CalledProcessError if Caddy dies, cancelling the group.
|
||||
"""
|
||||
caddy_path = shutil.which("caddy")
|
||||
if not caddy_path:
|
||||
logger.warning("Caddy not found. Install it to use --caddy option.")
|
||||
@@ -86,57 +90,56 @@ async def run_caddy(
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
proc.stdin.write(caddyfile.encode())
|
||||
await proc.stdin.drain()
|
||||
proc.stdin.close()
|
||||
try:
|
||||
proc.stdin.write(caddyfile.encode())
|
||||
await proc.stdin.drain()
|
||||
proc.stdin.close()
|
||||
|
||||
# Wait for ready signal or failure
|
||||
while True:
|
||||
if proc.returncode is not None:
|
||||
remaining = await proc.stderr.read()
|
||||
for line in remaining.decode().splitlines():
|
||||
if line:
|
||||
logger.info("caddy: %s", line)
|
||||
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
|
||||
raise SystemExit(1)
|
||||
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
decoded = line.decode().rstrip()
|
||||
if "serving initial configuration" in decoded:
|
||||
break
|
||||
|
||||
# Parse and show errors during startup
|
||||
if decoded:
|
||||
try:
|
||||
log = json.loads(decoded)
|
||||
level = log.get("level", "")
|
||||
if level in ("error", "fatal", "warn"):
|
||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||
except json.JSONDecodeError:
|
||||
if "error" in decoded.lower() or "fatal" in decoded.lower():
|
||||
logger.warning("caddy: %s", decoded)
|
||||
|
||||
# Start background task to drain stderr
|
||||
async def drain_caddy_stderr():
|
||||
# Wait for ready signal or failure
|
||||
while True:
|
||||
if proc.returncode is not None:
|
||||
await log_caddy_stderr(proc.stderr, starting=True)
|
||||
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
|
||||
raise CalledProcessError(proc.returncode, cmd)
|
||||
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
decoded = line.decode().rstrip()
|
||||
if decoded:
|
||||
try:
|
||||
log = json.loads(decoded)
|
||||
level = log.get("level", "")
|
||||
if level in ("error", "fatal", "warn"):
|
||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||
except json.JSONDecodeError:
|
||||
pass # Ignore non-JSON output after startup
|
||||
continue
|
||||
|
||||
asyncio.create_task(drain_caddy_stderr())
|
||||
return proc
|
||||
decoded = line.decode().rstrip()
|
||||
if "serving initial configuration" in decoded:
|
||||
break
|
||||
|
||||
log_caddy_line(decoded, starting=True)
|
||||
|
||||
# Drain stderr until Caddy exits
|
||||
await proc.wait()
|
||||
await log_caddy_stderr(proc.stderr)
|
||||
raise CalledProcessError(proc.returncode, cmd)
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
proc.terminate()
|
||||
await proc.wait()
|
||||
|
||||
|
||||
def log_caddy_line(decoded: str, *, starting: bool = False) -> None:
|
||||
"""Log one Caddy stderr line (JSON during/after startup)."""
|
||||
if not decoded:
|
||||
return
|
||||
try:
|
||||
log = json.loads(decoded)
|
||||
level = log.get("level", "")
|
||||
if level in ("error", "fatal", "warn"):
|
||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||
except json.JSONDecodeError:
|
||||
if starting and ("error" in decoded.lower() or "fatal" in decoded.lower()):
|
||||
logger.warning("caddy: %s", decoded)
|
||||
|
||||
|
||||
async def log_caddy_stderr(stream: asyncio.StreamReader, *, starting: bool = False) -> None:
|
||||
"""Drain and log remaining Caddy stderr."""
|
||||
while line := await stream.readline():
|
||||
log_caddy_line(line.decode().rstrip(), starting=starting)
|
||||
|
||||
|
||||
def _split_multi(values: list[str] | None) -> list[str]:
|
||||
@@ -204,22 +207,25 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
caddy_origins.append(f"https://{rp_id}")
|
||||
seen: set = set()
|
||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
|
||||
pg._procs.append(caddy_proc)
|
||||
pg._cmds[caddy_proc.pid] = "caddy"
|
||||
pg.create_task(run_caddy(caddy_origins, viteurl, backurl))
|
||||
|
||||
pg.create_task(check_ports_free(viteurl, backurl))
|
||||
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*paskia)
|
||||
await pg.spawn(*paskia, vital=True)
|
||||
await pg.wait(
|
||||
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
|
||||
)
|
||||
await pg.spawn(*vite, cwd=frontend_path)
|
||||
await pg.spawn(*vite, cwd=frontend_path, vital=True)
|
||||
|
||||
|
||||
def main():
|
||||
tracerite.load()
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser = argparse.ArgumentParser(
|
||||
add_help=False,
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
@@ -243,8 +249,12 @@ def main():
|
||||
)
|
||||
args, remaining = parser.parse_known_args()
|
||||
|
||||
with suppress(KeyboardInterrupt):
|
||||
try:
|
||||
asyncio.run(run_devserver(args, remaining))
|
||||
except* KeyboardInterrupt:
|
||||
pass # user stopped the devserver: normal exit
|
||||
except* subprocess.SubprocessError, RuntimeError:
|
||||
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
|
||||
@@ -10,21 +10,31 @@ from pathlib import Path
|
||||
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
# Duplicated from fastapi_vue.logging because build environment is isolated
|
||||
_LEVEL_EMOJI = {
|
||||
logging.DEBUG: "🐛",
|
||||
logging.INFO: "🔷",
|
||||
logging.WARNING: "❗",
|
||||
logging.ERROR: "🛑",
|
||||
logging.CRITICAL: "🚨",
|
||||
}
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
class _Formatter(logging.Formatter):
|
||||
"""Emoji level prefix formatter, mirroring fastapi_vue.logging.Formatter."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
emoji = _LEVEL_EMOJI.get(record.levelno)
|
||||
prefix = f"{emoji} " if emoji else f"{record.levelname}: "
|
||||
return prefix + record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
_handler.setFormatter(_Formatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # own handler; do not double-print via a configured root
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
|
||||
@@ -1,108 +1,87 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from asyncio.subprocess import Process
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from subprocess import CalledProcessError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Awaitable
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
class ProcessGroup(asyncio.TaskGroup):
|
||||
"""TaskGroup with structured ownership of async subprocesses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty process tracking."""
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||
"""Set the grace period before terminate() escalates to kill()."""
|
||||
super().__init__()
|
||||
self._terminate_timeout = terminate_timeout
|
||||
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
*cmd: str,
|
||||
cwd: str | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||
) -> Process:
|
||||
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
async def run() -> None:
|
||||
name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._cmds[proc] = cmd
|
||||
started.set_result(proc)
|
||||
except Exception as e: # noqa: BLE001
|
||||
started.set_exception(e)
|
||||
return
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
returncode = await proc.wait()
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
with suppress(asyncio.CancelledError):
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
),
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
with suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
if vital:
|
||||
logger.warning("Vital process %s exited", name)
|
||||
raise CalledProcessError(returncode, cmd)
|
||||
|
||||
started = asyncio.get_running_loop().create_future()
|
||||
self.create_task(run())
|
||||
return await asyncio.shield(started)
|
||||
|
||||
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||
"""Wait concurrently and return results in argument order."""
|
||||
|
||||
async def task(w: Process | Awaitable) -> Any: # noqa: ANN401
|
||||
if not isinstance(w, Process):
|
||||
return await w
|
||||
if retcode := await w.wait():
|
||||
cmd = self._cmds[w]
|
||||
logger.warning(
|
||||
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
|
||||
)
|
||||
raise CalledProcessError(retcode, cmd)
|
||||
return retcode
|
||||
|
||||
async with asyncio.TaskGroup() as group:
|
||||
tasks = [group.create_task(task(w)) for w in waitables]
|
||||
|
||||
return tuple(task.result() for task in tasks)
|
||||
|
||||
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||
@@ -128,31 +107,32 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
|
||||
writer.close()
|
||||
except OSError, EOFError, ValueError, TimeoutError:
|
||||
return None
|
||||
for line in data.decode("latin-1").split("\r\n"):
|
||||
for line in data.decode(errors="replace").split("\r\n"):
|
||||
if line.lower().startswith("server:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return line[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
"""Verify URLs are not responding (ports are free).
|
||||
|
||||
async def check(url: str) -> None:
|
||||
server = await http_get_server(url, timeout=0.1)
|
||||
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||
RuntimeError (handled like a failed process) if any URL responds.
|
||||
"""
|
||||
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||
for url, server in zip(urls, servers, strict=True):
|
||||
if server is not None:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"Conflicting %s already running at %s", server or "server", url
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
await asyncio.gather(*[check(url) for url in urls])
|
||||
raise RuntimeError(url)
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Use empty path to disable the check and make this return immediately.
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||
"""
|
||||
if not path:
|
||||
return
|
||||
@@ -162,8 +142,8 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
logger.error("Backend at %s didn't start in time", url)
|
||||
raise RuntimeError(url)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Release build for paskia (PyPI) and paskia-js (npm).
|
||||
|
||||
Usage: release.py [patch|minor|major] (default: patch)
|
||||
|
||||
Bumps the version from the latest vX.Y.Z tag, commits "Release x.y.z" with
|
||||
the paskia-js version bump and tags it, then builds both packages from a
|
||||
clean slate. On failure the release commit and tag are rolled back.
|
||||
Publishing is left to the user; the command is printed on success.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Build outputs removed before building
|
||||
ARTIFACTS = ["dist", "paskia-js/dist", "paskia/frontend-build", "build"]
|
||||
|
||||
# Dependency state removed for a fresh upstream resolve (all untracked)
|
||||
JS_DIRS = ["paskia-js", "frontend"]
|
||||
JS_JUNK = ["node_modules", "package-lock.json", "deno.lock", "bun.lock"]
|
||||
|
||||
BUMPS = ("patch", "minor", "major")
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path = REPO_ROOT) -> None:
|
||||
print(f"### {' '.join(cmd)}")
|
||||
subprocess.run(cmd, cwd=cwd, check=True) # noqa: S603
|
||||
|
||||
|
||||
def abort(msg: str) -> None:
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
return subprocess.run( # noqa: S603
|
||||
["git", *args], cwd=REPO_ROOT, check=True, capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def check_clean_tree() -> None:
|
||||
status = git("status", "--porcelain")
|
||||
if status:
|
||||
print(status, file=sys.stderr)
|
||||
abort("working tree is not clean; commit or stash all changes first")
|
||||
|
||||
|
||||
def latest_version() -> tuple[int, int, int]:
|
||||
"""Highest vX.Y.Z tag, as a tuple."""
|
||||
tags = []
|
||||
for tag in git("tag", "--list", "v*").splitlines():
|
||||
parts = tag.removeprefix("v").split(".")
|
||||
if len(parts) == 3 and all(p.isdigit() for p in parts):
|
||||
tags.append(tuple(int(p) for p in parts))
|
||||
if not tags:
|
||||
abort("no existing vX.Y.Z tags found")
|
||||
return max(tags)
|
||||
|
||||
|
||||
def next_version(bump: str) -> tuple[int, int, int]:
|
||||
major, minor, patch = latest_version()
|
||||
if bump == "major":
|
||||
return (major + 1, 0, 0)
|
||||
if bump == "minor":
|
||||
return (major, minor + 1, 0)
|
||||
return (major, minor, patch + 1)
|
||||
|
||||
|
||||
def set_js_version(version: str) -> None:
|
||||
pkg_path = REPO_ROOT / "paskia-js/package.json"
|
||||
pkg = json.loads(pkg_path.read_text())
|
||||
if pkg.get("version") == version:
|
||||
return
|
||||
print(f"paskia-js/package.json: {pkg.get('version')} -> {version}")
|
||||
pkg["version"] = version
|
||||
pkg_path.write_text(json.dumps(pkg, indent=2) + "\n")
|
||||
|
||||
|
||||
def remove(path: Path, rel: str) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
print(f"rm -rf {rel}")
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
|
||||
|
||||
def clean() -> None:
|
||||
for rel in ARTIFACTS:
|
||||
remove(REPO_ROOT / rel, rel)
|
||||
for d in JS_DIRS:
|
||||
for junk in JS_JUNK:
|
||||
remove(REPO_ROOT / d / junk, f"{d}/{junk}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
bump = sys.argv[1] if len(sys.argv) == 2 else "patch"
|
||||
if len(sys.argv) > 2 or bump not in BUMPS:
|
||||
print(__doc__)
|
||||
raise SystemExit(1)
|
||||
|
||||
check_clean_tree()
|
||||
version_tuple = next_version(bump)
|
||||
version = ".".join(str(p) for p in version_tuple)
|
||||
tag = f"v{version}"
|
||||
if tag in git("tag", "--list", tag).splitlines():
|
||||
abort(f"tag {tag} already exists")
|
||||
print(f"Release version: {version}")
|
||||
|
||||
previous_head = git("rev-parse", "HEAD")
|
||||
released = False
|
||||
try:
|
||||
# Clean before committing: only the uv build (hatch-vcs) depends on
|
||||
# the tag, so the release commit can be made from a clean slate.
|
||||
clean()
|
||||
set_js_version(version)
|
||||
run(["git", "add", "paskia-js/package.json"])
|
||||
run(["git", "commit", "-m", f"Release {version}"])
|
||||
run(["git", "tag", tag])
|
||||
released = True
|
||||
|
||||
# uv build runs the hatch hook that builds paskia-js and the Vue
|
||||
# frontend into paskia/frontend-build with fresh dependencies.
|
||||
run(["uv", "build"])
|
||||
# Explicit paskia-js build: verifies the package standalone and
|
||||
# leaves paskia-js/dist ready for npm publish.
|
||||
run(["npm", "install"], cwd=REPO_ROOT / "paskia-js")
|
||||
run(["npm", "run", "build"], cwd=REPO_ROOT / "paskia-js")
|
||||
except BaseException:
|
||||
if released:
|
||||
print("Build failed; rolling back the release commit and tag.", file=sys.stderr)
|
||||
subprocess.run(["git", "tag", "-d", tag], cwd=REPO_ROOT, check=False) # noqa: S603
|
||||
# The tree was clean before the release commit, so a hard reset
|
||||
# back to it is safe.
|
||||
subprocess.run(["git", "reset", "--hard", previous_head], cwd=REPO_ROOT, check=False) # noqa: S603
|
||||
raise
|
||||
|
||||
# Push the release commit to the tracking remote, then the new tag.
|
||||
# Not rolled back on failure: the local release is intact, just push again.
|
||||
try:
|
||||
run(["git", "push"])
|
||||
run(["git", "push", "--tags"])
|
||||
except subprocess.CalledProcessError:
|
||||
abort("push failed; the release commit and tag exist locally, push manually")
|
||||
|
||||
print(f"\nBuild completed successfully for version {version}.")
|
||||
print("To publish, review the artifacts and run:")
|
||||
print("\nuv publish && cd paskia-js && npm publish")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -941,6 +941,22 @@ class TestLegacyConversion:
|
||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||
assert config.domains["example.com"].origins == {"**.example.com": True}
|
||||
|
||||
def test_convert_auth_host_with_empty_origins_keeps_wildcard(self, tmp_path):
|
||||
"""A dedicated auth host with no configured origins still allowed
|
||||
the whole rp-id domain in the legacy format — the auth host must
|
||||
not become the only allowed origin."""
|
||||
src_file = tmp_path / "main.db"
|
||||
asyncio.run(
|
||||
_write_legacy(
|
||||
src_file,
|
||||
LegacyConfig(rp_id="example.com", auth_host="auth.example.com"),
|
||||
)
|
||||
)
|
||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||
origins = config.domains["example.com"].origins
|
||||
assert origins["**.example.com"] is True
|
||||
assert origins["auth.example.com"] == OriginEntry(auth_host=True)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Transaction log censoring
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for startup box sign-in summaries (paskia/util/startupbox.py).
|
||||
|
||||
Pruning must mirror the actual origin matching in sansio._allowlisted:
|
||||
wildcards cover https origins (any port) under their base, except under
|
||||
localhost where any scheme and any port match.
|
||||
"""
|
||||
|
||||
from paskia.util.startupbox import _signin_summary
|
||||
|
||||
|
||||
def test_auth_host_pruned_under_full_wildcard():
|
||||
"""'**.vasanko.com' already covers auth.vasanko.com."""
|
||||
keys = ["**.vasanko.com", "auth.vasanko.com"]
|
||||
assert _signin_summary(keys, "vasanko.com") == "all subdomains"
|
||||
|
||||
|
||||
def test_http_origin_not_covered_by_https_wildcard():
|
||||
"""Plain http outside localhost is not wildcard-covered; stays listed."""
|
||||
keys = ["**.example.com", "http://app.example.com"]
|
||||
summary = _signin_summary(keys, "example.com")
|
||||
assert summary == "all subdomains, http://app.example.com"
|
||||
|
||||
|
||||
def test_localhost_wildcard_covers_any_scheme_and_port():
|
||||
keys = ["**.localhost", "http://localhost:3000", "localhost:8080"]
|
||||
assert _signin_summary(keys, "localhost") == "all subdomains"
|
||||
|
||||
|
||||
def test_https_port_key_covered_by_wildcard():
|
||||
"""Wildcards match https origins at any port, so 'host:8443' is redundant."""
|
||||
keys = ["**.example.com", "app.example.com:8443"]
|
||||
assert _signin_summary(keys, "example.com") == "all subdomains"
|
||||
|
||||
|
||||
def test_single_level_wildcard_pruning():
|
||||
"""'*.example.com' covers one subdomain level only."""
|
||||
keys = ["*.example.com", "app.example.com", "deep.app.example.com"]
|
||||
summary = _signin_summary(keys, "example.com")
|
||||
assert summary == "subdomains, deep.app.example.com"
|
||||
|
||||
|
||||
def test_no_wildcard_keeps_all_entries():
|
||||
keys = ["auth.example.com", "app.example.com"]
|
||||
summary = _signin_summary(keys, "example.com")
|
||||
assert summary == "app.example.com, auth.example.com"
|
||||
|
||||
|
||||
def test_entries_outside_wildcard_base_kept():
|
||||
keys = ["**.app.example.com", "auth.example.com"]
|
||||
summary = _signin_summary(keys, "example.com")
|
||||
assert summary == "all subdomains of app.example.com, auth.example.com"
|
||||
Reference in New Issue
Block a user