Compare commits
46 commits
45f38b28d3
...
6134e9a6ec
Author | SHA1 | Date | |
---|---|---|---|
Julien Oculi | 6134e9a6ec | ||
Julien Oculi | c766a00980 | ||
Julien Oculi | 80a2eed2ee | ||
Julien Oculi | 8d316ae52e | ||
Julien Oculi | b01bbfdb5b | ||
Julien Oculi | ee69d545b5 | ||
Julien Oculi | 91eefc520f | ||
Julien Oculi | db07adbe22 | ||
Julien Oculi | 9ce155570d | ||
Julien Oculi | 6f5d2c1535 | ||
Julien Oculi | e590767b29 | ||
Julien Oculi | 09bab92bca | ||
Julien Oculi | fb7871ea21 | ||
Julien Oculi | c74687ca64 | ||
Julien Oculi | 021d9e689b | ||
Julien Oculi | e44401415a | ||
Julien Oculi | e9b3437775 | ||
Julien Oculi | deee33056c | ||
Julien Oculi | 0c8789435c | ||
Julien Oculi | 41ba7e2904 | ||
Julien Oculi | 0c14e63808 | ||
Julien Oculi | c4cd04eb95 | ||
Julien Oculi | ca482e8956 | ||
Julien Oculi | 7ea95c67c4 | ||
Julien Oculi | eb13af1ac8 | ||
Julien Oculi | b032fe2161 | ||
Julien Oculi | c38ae17881 | ||
Julien Oculi | 8dc12c4e0d | ||
Julien Oculi | 7a6497a5fe | ||
Julien Oculi | 0f4c187c26 | ||
Julien Oculi | bdbe932872 | ||
Julien Oculi | 908c820cb0 | ||
Julien Oculi | 61d072cb06 | ||
Julien Oculi | 6ae5348e2e | ||
Julien Oculi | f2348b0177 | ||
Julien Oculi | 756c5564b3 | ||
Julien Oculi | e2c8313aa3 | ||
Julien Oculi | a1844176bb | ||
Julien Oculi | ca79b4a20d | ||
Julien Oculi | c971542ec8 | ||
Julien Oculi | 8d7ad1dc2f | ||
Julien Oculi | 5ff284c936 | ||
Julien Oculi | ad296fe1b8 | ||
Julien Oculi | cc9235d64f | ||
Julien Oculi | 5dd9f00599 | ||
Julien Oculi | 503d8425c2 |
20
.vscode/settings.json
vendored
20
.vscode/settings.json
vendored
|
@ -17,10 +17,26 @@
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.tsx": "${capture}.*"
|
"*.tsx": "${capture}.*"
|
||||||
},
|
},
|
||||||
"cSpell.words": ["technoshop", "Technoshop", "univoq"],
|
"cSpell.words": [
|
||||||
|
"magiclink",
|
||||||
|
"RPID",
|
||||||
|
"simplewebauthn",
|
||||||
|
"startserviceworker",
|
||||||
|
"technoshop",
|
||||||
|
"Technoshop",
|
||||||
|
"univoq"
|
||||||
|
],
|
||||||
"cssvar.enable": true,
|
"cssvar.enable": true,
|
||||||
"cssvar.files": ["./_fresh/*"],
|
"cssvar.files": ["./_fresh/*"],
|
||||||
"conventionalCommits.scopes": ["css", "config", "ui", "pwa"],
|
"conventionalCommits.scopes": [
|
||||||
|
"css",
|
||||||
|
"config",
|
||||||
|
"ui",
|
||||||
|
"pwa",
|
||||||
|
"api",
|
||||||
|
"ux",
|
||||||
|
"route"
|
||||||
|
],
|
||||||
"[ignore]": {
|
"[ignore]": {
|
||||||
"editor.defaultFormatter": "foxundermoon.shell-format"
|
"editor.defaultFormatter": "foxundermoon.shell-format"
|
||||||
}
|
}
|
||||||
|
|
5
components/ProgressiveWebApp.tsx
Normal file
5
components/ProgressiveWebApp.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import RegisterServiceWorker from '../islands/RegisterServiceWorker.tsx'
|
||||||
|
|
||||||
|
export function ProgressiveWebApp() {
|
||||||
|
return <RegisterServiceWorker />
|
||||||
|
}
|
53
deno.json
53
deno.json
|
@ -8,38 +8,37 @@
|
||||||
"build": "deno run -A dev.ts build",
|
"build": "deno run -A dev.ts build",
|
||||||
"preview": "deno run -A main.ts",
|
"preview": "deno run -A main.ts",
|
||||||
"update": "deno run -A -r https://fresh.deno.dev/update .",
|
"update": "deno run -A -r https://fresh.deno.dev/update .",
|
||||||
"serve": "deno task preview"
|
"serve": "deno task preview",
|
||||||
|
"dev:add_package": "deno run --allow-net=git.cohabit.fr --allow-read=. --allow-write=./deno.json,./packages --allow-run=git,deno ./scripts/add_package.ts"
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": { "singleQuote": true, "semiColons": false, "useTabs": true },
|
||||||
"singleQuote": true,
|
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
|
||||||
"semiColons": false,
|
"exclude": ["**/_fresh/*", "packages/"],
|
||||||
"useTabs": true
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"rules": {
|
|
||||||
"tags": [
|
|
||||||
"fresh",
|
|
||||||
"recommended"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"**/_fresh/*"
|
|
||||||
],
|
|
||||||
"imports": {
|
"imports": {
|
||||||
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
|
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||||
"preact": "https://esm.sh/preact@10.19.6",
|
"$std/": "https://deno.land/std@0.208.0/",
|
||||||
"preact/": "https://esm.sh/preact@10.19.6/",
|
"@cohabit/cohamail/": "./packages/@cohabit__cohamail@0.2.1/",
|
||||||
|
"@cohabit/ressources_manager/": "./packages/@cohabit__ressources_manager@0.1.0/",
|
||||||
|
"@jotsr/delayed": "jsr:@jotsr/delayed@^2.1.1",
|
||||||
|
"@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0",
|
||||||
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
|
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
|
||||||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
|
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
|
||||||
"$std/": "https://deno.land/std@0.208.0/",
|
"@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0",
|
||||||
"univoq": "https://deno.land/x/univoq@0.2.0/mod.ts",
|
"@simplewebauthn/server": "npm:@simplewebauthn/server@^10.0.0",
|
||||||
|
"@simplewebauthn/types": "npm:@simplewebauthn/types@^10.0.0",
|
||||||
|
"@std/encoding": "jsr:@std/encoding@^0.224.3",
|
||||||
|
"@std/http": "jsr:@std/http@^0.224.4",
|
||||||
"@univoq/": "https://deno.land/x/univoq@0.2.0/",
|
"@univoq/": "https://deno.land/x/univoq@0.2.0/",
|
||||||
"css_bundler": "../../../github.com/JOTSR/fresh_css_bundler/plugin.ts",
|
"gfm": "https://deno.land/x/gfm@0.6.0/mod.ts",
|
||||||
"gfm": "https://deno.land/x/gfm@0.6.0/mod.ts"
|
"preact": "https://esm.sh/preact@10.19.6",
|
||||||
|
"preact/": "https://esm.sh/preact@10.19.6/",
|
||||||
|
"univoq": "https://deno.land/x/univoq@0.2.0/mod.ts",
|
||||||
|
"web-push": "npm:web-push@^3.6.7"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
|
||||||
"jsx": "react-jsx",
|
"workspaces": [
|
||||||
"jsxImportSource": "preact"
|
"packages/@cohabit__cohamail@0.2.1",
|
||||||
}
|
"packages/@cohabit__ressources_manager@0.1.0"
|
||||||
|
],
|
||||||
|
"unstable": ["kv"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
import { defineConfig } from '$fresh/server.ts'
|
import { defineConfig } from '$fresh/server.ts'
|
||||||
import { cssBundler } from 'css_bundler'
|
import { cssBundler } from '@jotsr/smart-css-bundler/fresh'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
cssBundler(
|
cssBundler(['./src/stylesheets/main.css'], { bundleSubDir: 'css' }),
|
||||||
import.meta.resolve('./src/stylesheets'),
|
|
||||||
import.meta.resolve('./cache'),
|
|
||||||
{ logLevel: 'error' },
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
8
islands/LoginForm.css
Normal file
8
islands/LoginForm.css
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.island__login_form__form {
|
||||||
|
padding: var(--_gap);
|
||||||
|
background-color: var(--_translucent);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
gap: var(--_gap);
|
||||||
|
align-items: center;
|
||||||
|
}
|
114
islands/LoginForm.tsx
Normal file
114
islands/LoginForm.tsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
|
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'
|
||||||
|
import { Button, Input } from 'univoq'
|
||||||
|
import type {
|
||||||
|
WebAuthnLoginFinishPayload,
|
||||||
|
WebAuthnLoginStartPayload,
|
||||||
|
} from '../routes/api/webauthn/login/[step].ts'
|
||||||
|
import { requestApi } from '../src/utils.ts'
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={connect}
|
||||||
|
method='POST'
|
||||||
|
action=''
|
||||||
|
className='island__login_form__form'
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label='Email'
|
||||||
|
name='email'
|
||||||
|
autoComplete='email'
|
||||||
|
type='email'
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='Utiliser une Passkey'
|
||||||
|
name='passkey'
|
||||||
|
type='checkbox'
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
<Button label='Me connecter' variant='primary'>Me connecter</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginFormFields = {
|
||||||
|
email: string
|
||||||
|
passkey: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(event: Event) {
|
||||||
|
if (!(event instanceof SubmitEvent)) return false
|
||||||
|
event.preventDefault()
|
||||||
|
const form = event.target as HTMLFormElement
|
||||||
|
const fields = formJSON<LoginFormFields>(form)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// User disable passkey
|
||||||
|
if (!fields.passkey) {
|
||||||
|
throw new Error('User refused passkey')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PassKey connection
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
throw new Error('WebAuthn is not supported by your browser')
|
||||||
|
}
|
||||||
|
|
||||||
|
await webAuthnLogin(fields)
|
||||||
|
|
||||||
|
// Reload UI
|
||||||
|
location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
// Check is user stop passkey connection
|
||||||
|
if (error instanceof Error && error.name === 'NotAllowedError') {
|
||||||
|
form.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Else use magic link
|
||||||
|
await magicLinkLogin(fields)
|
||||||
|
|
||||||
|
// Reload UI
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function magicLinkLogin(fields: LoginFormFields) {
|
||||||
|
await requestApi('magiclink', 'POST', fields)
|
||||||
|
console.log('Un lien de connection vous a été envoyé par mail !')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function webAuthnLogin(fields: LoginFormFields) {
|
||||||
|
// Send WebAuthn authentication options
|
||||||
|
const authenticationOptions = await requestApi<
|
||||||
|
WebAuthnLoginStartPayload,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON
|
||||||
|
>('webauthn/login/start', 'POST', fields)
|
||||||
|
|
||||||
|
// Pass the options to the authenticator and wait for a response
|
||||||
|
const authentication = await startAuthentication(authenticationOptions)
|
||||||
|
|
||||||
|
// Verify authentication
|
||||||
|
const verification = await requestApi<
|
||||||
|
WebAuthnLoginFinishPayload,
|
||||||
|
{ verified: boolean }
|
||||||
|
>('webauthn/login/finish', 'POST', authentication)
|
||||||
|
|
||||||
|
// Show UI appropriate for the `verified` status
|
||||||
|
if (verification.verified) {
|
||||||
|
console.log('Success!')
|
||||||
|
} else {
|
||||||
|
console.error('PassKey was not verified!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebAuthnSupported(): boolean {
|
||||||
|
return 'credentials' in navigator
|
||||||
|
}
|
||||||
|
|
||||||
|
function formJSON<T extends Record<string, unknown>>(form: HTMLFormElement): T {
|
||||||
|
const formData = new FormData(form)
|
||||||
|
return Object.fromEntries(formData) as unknown as T
|
||||||
|
}
|
|
@ -19,7 +19,6 @@
|
||||||
|
|
||||||
.islands__more_box__button {
|
.islands__more_box__button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border: var(--_border-size) solid transparent;
|
border: var(--_border-size) solid transparent;
|
||||||
color: var(--_font-color);
|
color: var(--_font-color);
|
||||||
|
|
109
islands/PassKeyRegister.tsx
Normal file
109
islands/PassKeyRegister.tsx
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser'
|
||||||
|
import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'
|
||||||
|
import { Button, Input } from 'univoq'
|
||||||
|
import type {
|
||||||
|
WebAuthnRegisterFinishPayload,
|
||||||
|
WebAuthnRegisterStartPayload,
|
||||||
|
} from '../routes/api/webauthn/register/[step].ts'
|
||||||
|
import { requestApi } from '../src/utils.ts'
|
||||||
|
|
||||||
|
function isWebAuthnSupported(): boolean {
|
||||||
|
return 'credentials' in navigator
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegisterForm({ disabled }: { disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<form onSubmit={register} method='POST' action=''>
|
||||||
|
<Input label='Nom de la clé' name='name' required></Input>
|
||||||
|
<Button
|
||||||
|
label='Enregistrer une PassKey'
|
||||||
|
variant='primary'
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Enregistrer une PassKey
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PassKeyRegister() {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>Erreur: WebAuthn n'est pas supporté par votre navigateur</span>
|
||||||
|
<RegisterForm disabled />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>Enregistrer une PassKey pour cet appareil</span>
|
||||||
|
<RegisterForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterFormFields = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
async function register(event: Event) {
|
||||||
|
if (!(event instanceof SubmitEvent)) return false
|
||||||
|
event.preventDefault()
|
||||||
|
const form = event.target as HTMLFormElement
|
||||||
|
const fields = formJSON<RegisterFormFields>(form)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webAuthnRegister(fields)
|
||||||
|
} catch (cause) {
|
||||||
|
console.error(
|
||||||
|
new Error('passkey register failed', {
|
||||||
|
cause,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function webAuthnRegister(fields: RegisterFormFields) {
|
||||||
|
if (localStorage.getItem('webauthn-registered')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationOptions = await requestApi<
|
||||||
|
WebAuthnRegisterStartPayload,
|
||||||
|
PublicKeyCredentialCreationOptionsJSON
|
||||||
|
>('webauthn/register/start', 'POST', fields)
|
||||||
|
|
||||||
|
// Pass the options to the authenticator and wait for a response
|
||||||
|
const registration = await startRegistration(registrationOptions)
|
||||||
|
.catch((error) => {
|
||||||
|
// Some basic error handling
|
||||||
|
if (error.name === 'InvalidStateError') {
|
||||||
|
console.error(
|
||||||
|
'Authenticator was probably already registered by user',
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (registration === null) return
|
||||||
|
|
||||||
|
// Verify the registration
|
||||||
|
try {
|
||||||
|
await requestApi<
|
||||||
|
WebAuthnRegisterFinishPayload,
|
||||||
|
{ verified: boolean }
|
||||||
|
>('webauthn/register/finish', 'POST', registration)
|
||||||
|
console.log('Success!')
|
||||||
|
localStorage.setItem('webauthn-registered', 'true')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Oh no, something went wrong! Response:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formJSON<T extends Record<string, unknown>>(form: HTMLFormElement): T {
|
||||||
|
const formData = new FormData(form)
|
||||||
|
return Object.fromEntries(formData) as unknown as T
|
||||||
|
}
|
|
@ -1,6 +1,40 @@
|
||||||
export default function RegisterServiceWorker({ href }: { href: string }) {
|
import { requestApi } from '../src/utils.ts'
|
||||||
|
|
||||||
|
export default function RegisterServiceWorker() {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register(href)
|
import('./StartServiceWorker.tsx').then(async (mod) => {
|
||||||
|
const href = mod.default()
|
||||||
|
const registration = await navigator.serviceWorker.register(href, {
|
||||||
|
scope: '/',
|
||||||
|
type: 'module',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notification.requestPermission().then((permission) => {
|
||||||
|
// if (permission !== 'granted') return
|
||||||
|
|
||||||
|
// registration.showNotification('Notification permission granted', {
|
||||||
|
// body: 'Notification is ok.',
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
|
||||||
|
const subscription = await (async () => {
|
||||||
|
const currentSubscription = await registration.pushManager
|
||||||
|
.getSubscription()
|
||||||
|
if (currentSubscription) return currentSubscription
|
||||||
|
|
||||||
|
const applicationServerKey = await requestApi<void, string>(
|
||||||
|
'webpush/vapid',
|
||||||
|
'GET',
|
||||||
|
)
|
||||||
|
|
||||||
|
return await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey,
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
await requestApi('webpush/subscription', 'POST', subscription)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return <></>
|
return <></>
|
||||||
|
|
10
islands/StartServiceWorker.tsx
Normal file
10
islands/StartServiceWorker.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { main } from '../src/serviceworker/mod.ts'
|
||||||
|
|
||||||
|
const IS_SW = 'onpushsubscriptionchange' in self
|
||||||
|
|
||||||
|
export default function StartServiceWorker() {
|
||||||
|
if (IS_SW) {
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
return new URL(import.meta.url).pathname
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import { asset, Head, Partial } from '$fresh/runtime.ts'
|
||||||
import { type PageProps } from '$fresh/server.ts'
|
import { type PageProps } from '$fresh/server.ts'
|
||||||
import { Footer } from '../components/Footer.tsx'
|
import { Footer } from '../components/Footer.tsx'
|
||||||
import { Header } from '../components/Header.tsx'
|
import { Header } from '../components/Header.tsx'
|
||||||
import RegisterServiceWorker from '../islands/RegisterServiceWorker.tsx'
|
import { ProgressiveWebApp } from '../components/ProgressiveWebApp.tsx'
|
||||||
|
|
||||||
export default function App({ Component }: PageProps) {
|
export default function App({ Component }: PageProps) {
|
||||||
return (
|
return (
|
||||||
|
@ -53,7 +53,7 @@ export default function App({ Component }: PageProps) {
|
||||||
<Component />
|
<Component />
|
||||||
</Partial>
|
</Partial>
|
||||||
</main>
|
</main>
|
||||||
<RegisterServiceWorker href={asset('/sw.js')} />
|
<ProgressiveWebApp />
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
59
routes/_middleware.ts
Normal file
59
routes/_middleware.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { FreshContext } from '$fresh/server.ts'
|
||||||
|
import { getCookies, setCookie } from '@std/http/cookie'
|
||||||
|
import { SessionStore } from '../src/session/mod.ts'
|
||||||
|
|
||||||
|
export async function handler(request: Request, ctx: FreshContext) {
|
||||||
|
// Update fresh context state with session
|
||||||
|
ctx.state = { ...ctx.state, session: SessionStore.getFromRequest(request) }
|
||||||
|
|
||||||
|
// Allow service worker to serve root scope
|
||||||
|
const response = await ctx.next()
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname.endsWith('island-startserviceworker.js')) {
|
||||||
|
response.headers.set('Service-Worker-Allowed', '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start session
|
||||||
|
if (SessionStore.getFromRequest(request) === undefined) {
|
||||||
|
// Clear outdated cookies
|
||||||
|
for (const cookie in getCookies(request.headers)) {
|
||||||
|
setCookie(response.headers, {
|
||||||
|
name: cookie,
|
||||||
|
value: '',
|
||||||
|
path: '/',
|
||||||
|
expires: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
const session = SessionStore.createSession()
|
||||||
|
ctx.state = { ...ctx.state, session }
|
||||||
|
|
||||||
|
// Set session cookie
|
||||||
|
setCookie(response.headers, {
|
||||||
|
name: '_SESSION',
|
||||||
|
value: session.uuid,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Strict',
|
||||||
|
secure: true,
|
||||||
|
path: '/',
|
||||||
|
expires: SessionStore.maxAge,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set csrf
|
||||||
|
const csrf = crypto.randomUUID()
|
||||||
|
session.set('_csrf', csrf)
|
||||||
|
|
||||||
|
setCookie(response.headers, {
|
||||||
|
name: '_CSRF',
|
||||||
|
value: csrf,
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: 'Strict',
|
||||||
|
secure: true,
|
||||||
|
path: '/',
|
||||||
|
expires: SessionStore.maxAge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
17
routes/api/_middleware.ts
Normal file
17
routes/api/_middleware.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { FreshContext } from '$fresh/server.ts'
|
||||||
|
import { SessionStore } from '../../src/session/mod.ts'
|
||||||
|
import { respondApi } from '../../src/utils.ts'
|
||||||
|
|
||||||
|
export function handler(request: Request, ctx: FreshContext) {
|
||||||
|
// Check CSRF token
|
||||||
|
if (['POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'].includes(request.method)) {
|
||||||
|
const session = SessionStore.getFromRequest(request)
|
||||||
|
const csrf = session?.get('_csrf')
|
||||||
|
|
||||||
|
if (csrf === undefined || request.headers.get('X-CSRF-TOKEN') !== csrf) {
|
||||||
|
return respondApi('error', new Error('invalid csrf token'), 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.next()
|
||||||
|
}
|
145
routes/api/magiclink/index.ts
Normal file
145
routes/api/magiclink/index.ts
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
import 'npm:iterator-polyfill'
|
||||||
|
// Polyfill AsyncIterator
|
||||||
|
|
||||||
|
import { FreshContext, Handlers } from '$fresh/server.ts'
|
||||||
|
import { Contact, type Mail, send } from '@cohabit/cohamail/mod.ts'
|
||||||
|
import { magicLinkTemplate } from '@cohabit/cohamail/templates/mod.ts'
|
||||||
|
import { SessionStore } from '../../../src/session/mod.ts'
|
||||||
|
import { respondApi } from '../../../src/utils.ts'
|
||||||
|
import { sleep } from '@jotsr/delayed'
|
||||||
|
import { User } from '@cohabit/ressources_manager/src/models/mod.ts'
|
||||||
|
import { db } from '../../../src/db/mod.ts'
|
||||||
|
|
||||||
|
type MagicLinkInfos = {
|
||||||
|
remoteId: string
|
||||||
|
email: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByMail(email: string): Promise<User | undefined> {
|
||||||
|
const [user] = await db.ressource.user
|
||||||
|
.list((user) => user.mail === email)
|
||||||
|
.take(1)
|
||||||
|
.toArray()
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers = {
|
||||||
|
async POST(request, ctx) {
|
||||||
|
const { email } = await request.json() as { email: string }
|
||||||
|
|
||||||
|
// check email before continue
|
||||||
|
if (!/\S+@\S+\.\S+/.test(email)) {
|
||||||
|
return respondApi('error', new SyntaxError('empty or invalid email'), 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByMail(email)
|
||||||
|
|
||||||
|
// generate magic link
|
||||||
|
const token = crypto.randomUUID()
|
||||||
|
const endpoint =
|
||||||
|
`${ctx.url.origin}/api/magiclink?token=${token}&redirect=/profil`
|
||||||
|
|
||||||
|
// save token to session
|
||||||
|
const session = SessionStore.getFromRequest(request)
|
||||||
|
session?.flash<MagicLinkInfos>(`MAGIC_LINK__${token}`, {
|
||||||
|
email,
|
||||||
|
remoteId: remoteId(ctx),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// send mail to user
|
||||||
|
try {
|
||||||
|
if (user) {
|
||||||
|
const ip = ctx.remoteAddr.hostname
|
||||||
|
const device = request.headers.get('Sec-Ch-Ua-Platform') ?? undefined
|
||||||
|
|
||||||
|
await sendMagicLink(user, { device, ip, endpoint })
|
||||||
|
} else {
|
||||||
|
//! perform wait to prevent time attacks
|
||||||
|
await sleep(Math.random() * 5_000 + 2_000) //between 2s and 7s
|
||||||
|
}
|
||||||
|
return respondApi('success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MAGIC_LINK_SENDING', error)
|
||||||
|
return respondApi(
|
||||||
|
'error',
|
||||||
|
new Error(`unable to send mail to ${email}`),
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async GET(request, ctx) {
|
||||||
|
const token = ctx.url.searchParams.get('token')
|
||||||
|
const redirect = ctx.url.searchParams.get('redirect')
|
||||||
|
const session = SessionStore.getFromRequest(request)
|
||||||
|
|
||||||
|
// no session datas
|
||||||
|
if (session === undefined) {
|
||||||
|
return respondApi('error', 'no session datas', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// no token
|
||||||
|
if (token === null) {
|
||||||
|
return respondApi('error', 'no token provided', 400)
|
||||||
|
}
|
||||||
|
// wrong or timeout token
|
||||||
|
const entry = session.get<MagicLinkInfos>(`MAGIC_LINK__${token}`)
|
||||||
|
|
||||||
|
const lifespan = Date.now() - 10 * 60 * 1_000 // ten minutes
|
||||||
|
|
||||||
|
if (entry === undefined || entry.timestamp < lifespan) {
|
||||||
|
return respondApi('error', 'wrong token or timeout exceeded', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check remote id (same user/machine that has query the token)
|
||||||
|
if (entry.remoteId === remoteId(ctx)) {
|
||||||
|
const user = await getUserByMail(entry.email)
|
||||||
|
session.set('user', user)
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
return Response.redirect(new URL(redirect, ctx.basePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
return respondApi('success', user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return respondApi(
|
||||||
|
'error',
|
||||||
|
new Error(
|
||||||
|
'invalid id, use the same device/ip to query token and verify token',
|
||||||
|
),
|
||||||
|
401,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteId(
|
||||||
|
{ remoteAddr }: { remoteAddr: FreshContext['remoteAddr'] },
|
||||||
|
): string {
|
||||||
|
return `(${remoteAddr.transport}):${remoteAddr.hostname}:${remoteAddr.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMagicLink(
|
||||||
|
{ firstname, lastname, mail }: User,
|
||||||
|
{ device, ip, endpoint }: { device?: string; ip?: string; endpoint: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const message: Mail = {
|
||||||
|
from: Contact.expand('contact'),
|
||||||
|
to: [Contact.fromString(`${firstname} ${lastname} <${mail}>`)],
|
||||||
|
subject: 'Lien de connection pour FabLab Coh@bit',
|
||||||
|
body: magicLinkTemplate.builder({
|
||||||
|
device,
|
||||||
|
ip,
|
||||||
|
endpoint,
|
||||||
|
})!,
|
||||||
|
options: {
|
||||||
|
cc: [],
|
||||||
|
cci: [],
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(message)
|
||||||
|
}
|
151
routes/api/webauthn/login/[step].ts
Normal file
151
routes/api/webauthn/login/[step].ts
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
} from '@simplewebauthn/server'
|
||||||
|
import { getRelyingParty } from '../../../../src/webauthn/mod.ts'
|
||||||
|
import {
|
||||||
|
AuthenticationResponseJSON,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
} from '@simplewebauthn/types'
|
||||||
|
import { respondApi } from '../../../../src/utils.ts'
|
||||||
|
import type { SessionHandlers } from '../../../../src/session/mod.ts'
|
||||||
|
import { db } from '../../../../src/db/mod.ts'
|
||||||
|
import { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts'
|
||||||
|
import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts'
|
||||||
|
import { decodeBase64 } from '@std/encoding'
|
||||||
|
|
||||||
|
type Params = { step: 'start' | 'finish' }
|
||||||
|
|
||||||
|
export type WebAuthnLoginStartPayload = {
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebAuthnLoginFinishPayload = AuthenticationResponseJSON
|
||||||
|
|
||||||
|
export const handler: SessionHandlers = {
|
||||||
|
async POST(req, ctx) {
|
||||||
|
const relyingParty = getRelyingParty(ctx.url)
|
||||||
|
|
||||||
|
const { step } = ctx.params as Params
|
||||||
|
|
||||||
|
if (step === 'start') {
|
||||||
|
const { email } = await req.json() as WebAuthnLoginStartPayload
|
||||||
|
|
||||||
|
// Get user credentials
|
||||||
|
const [user] = await db.ressource.user.list((user) => user.mail === email)
|
||||||
|
.take(1).toArray()
|
||||||
|
// Resolve refs to credentials
|
||||||
|
const resolver = Ref.dbResolver(db)
|
||||||
|
const credentials = await Promise.all(user.credentials.map(resolver))
|
||||||
|
// Get user passkeys
|
||||||
|
const passkeys = credentials
|
||||||
|
.filter((credential): credential is Credential<'passkey'> =>
|
||||||
|
credential.category === 'passkey'
|
||||||
|
)
|
||||||
|
.map((credential) => credential.store)
|
||||||
|
|
||||||
|
// Flash current user and passkeys
|
||||||
|
ctx.state.session.flash('user-request', user)
|
||||||
|
ctx.state.session.flash('passkeys-request', passkeys)
|
||||||
|
|
||||||
|
if (passkeys.length === 0) {
|
||||||
|
return respondApi(
|
||||||
|
'error',
|
||||||
|
new Error('no passkey found for requested user'),
|
||||||
|
301,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: relyingParty.id,
|
||||||
|
allowCredentials: passkeys,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.state.session.flash('webauthn-login', options)
|
||||||
|
|
||||||
|
return respondApi('success', options)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'finish') {
|
||||||
|
const authentication = await req
|
||||||
|
.json() as WebAuthnLoginFinishPayload
|
||||||
|
|
||||||
|
const options = ctx.state.session.get<
|
||||||
|
PublicKeyCredentialRequestOptionsJSON
|
||||||
|
>('webauthn-login')
|
||||||
|
|
||||||
|
if (options === undefined) {
|
||||||
|
return respondApi(
|
||||||
|
'error',
|
||||||
|
new Error('no authentication options registered'),
|
||||||
|
301,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ctx.state.session.get<User>('user-request')
|
||||||
|
const passkey = ctx.state.session
|
||||||
|
.get<Passkey[]>('passkeys-request')
|
||||||
|
?.filter((passkey) => passkey.id === authentication.id)
|
||||||
|
.at(0)
|
||||||
|
|
||||||
|
if (passkey === undefined) {
|
||||||
|
return respondApi(
|
||||||
|
'error',
|
||||||
|
new Error('no passkey found for requested user'),
|
||||||
|
301,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response: authentication,
|
||||||
|
expectedChallenge: options.challenge,
|
||||||
|
expectedOrigin: relyingParty.origin,
|
||||||
|
expectedRPID: relyingParty.id,
|
||||||
|
requireUserVerification: true,
|
||||||
|
authenticator: {
|
||||||
|
credentialID: passkey.id,
|
||||||
|
credentialPublicKey: decodeBase64(passkey.publicKey),
|
||||||
|
counter: passkey.counter,
|
||||||
|
transports: passkey.transports,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { authenticationInfo, verified } = verification
|
||||||
|
|
||||||
|
if (authenticationInfo === undefined) {
|
||||||
|
throw new Error('no authentication info found from verification')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { newCounter } = authenticationInfo
|
||||||
|
|
||||||
|
passkey.counter = newCounter
|
||||||
|
|
||||||
|
// Update credential store
|
||||||
|
const [credential] = await db.ressource.credential.list(
|
||||||
|
(credential) => {
|
||||||
|
if (credential.category !== 'passkey') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (credential as Credential<'passkey'>).store.id === passkey.id
|
||||||
|
},
|
||||||
|
).toArray()
|
||||||
|
|
||||||
|
// Save credential to db
|
||||||
|
await db.ressource.credential.set([
|
||||||
|
credential.update({ store: passkey }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// log user
|
||||||
|
ctx.state.session.set('user', user)
|
||||||
|
|
||||||
|
return respondApi('success', { verified })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return respondApi('error', error, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return respondApi('error', new Error('unknown step'), 400)
|
||||||
|
},
|
||||||
|
}
|
144
routes/api/webauthn/register/[step].ts
Normal file
144
routes/api/webauthn/register/[step].ts
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
import {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
} from '@simplewebauthn/server'
|
||||||
|
import type {
|
||||||
|
PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
RegistrationResponseJSON,
|
||||||
|
} from '@simplewebauthn/types'
|
||||||
|
import { respondApi } from '../../../../src/utils.ts'
|
||||||
|
import { SessionHandlers } from '../../../../src/session/mod.ts'
|
||||||
|
|
||||||
|
//TODO improve workspace imports
|
||||||
|
import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts'
|
||||||
|
import { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts'
|
||||||
|
import { getRelyingParty } from '../../../../src/webauthn/mod.ts'
|
||||||
|
import { encodeBase64 } from '@std/encoding'
|
||||||
|
import { db } from '../../../../src/db/mod.ts'
|
||||||
|
|
||||||
|
type Params = { step: 'start' | 'finish' }
|
||||||
|
|
||||||
|
export type WebAuthnRegisterStartPayload = { name: string }
|
||||||
|
|
||||||
|
export type WebAuthnRegisterFinishPayload = RegistrationResponseJSON
|
||||||
|
|
||||||
|
export const handler: SessionHandlers = {
|
||||||
|
async POST(req, ctx) {
|
||||||
|
const relyingParty = getRelyingParty(ctx.url)
|
||||||
|
|
||||||
|
const { step } = ctx.params as Params
|
||||||
|
const user = ctx.state.session.get<User>('user')
|
||||||
|
|
||||||
|
if (user === undefined) {
|
||||||
|
return respondApi(
|
||||||
|
'error',
|
||||||
|
new Error('no logged user in current session'),
|
||||||
|
401,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'start') {
|
||||||
|
const { name } = await req.json() as WebAuthnRegisterStartPayload
|
||||||
|
|
||||||
|
// Get user credentials
|
||||||
|
// Ensure latest user datas
|
||||||
|
const dbUser = await db.ressource.user.get(user)
|
||||||
|
// Resolve refs to credentials
|
||||||
|
const resolver = Ref.dbResolver(db)
|
||||||
|
const credentials = await Promise.all(dbUser.credentials.map(resolver))
|
||||||
|
const excludeCredentials = credentials
|
||||||
|
.filter((credential): credential is Credential<'passkey'> =>
|
||||||
|
credential.category === 'passkey'
|
||||||
|
)
|
||||||
|
.map((credential) => credential.store)
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: relyingParty.name,
|
||||||
|
rpID: relyingParty.id,
|
||||||
|
userName: user.login,
|
||||||
|
attestationType: 'none',
|
||||||
|
excludeCredentials,
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'preferred',
|
||||||
|
userVerification: 'preferred',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.state.session.flash('webauthn-registration', { name, options })
|
||||||
|
|
||||||
|
return respondApi('success', options)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'finish') {
|
||||||
|
const registration = await req.json() as WebAuthnRegisterFinishPayload
|
||||||
|
|
||||||
|
const { name, options } = ctx.state.session.get<
|
||||||
|
{ name: string; options: PublicKeyCredentialCreationOptionsJSON }
|
||||||
|
>('webauthn-registration')!
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (options === undefined) {
|
||||||
|
throw new Error(`no registration found for ${user.mail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response: registration,
|
||||||
|
expectedChallenge: options.challenge,
|
||||||
|
expectedOrigin: relyingParty.origin,
|
||||||
|
expectedRPID: relyingParty.id,
|
||||||
|
requireUserVerification: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { registrationInfo } = verification
|
||||||
|
|
||||||
|
if (registrationInfo === undefined) {
|
||||||
|
throw new Error('no registration info found from verification')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
credentialID,
|
||||||
|
credentialPublicKey,
|
||||||
|
counter,
|
||||||
|
credentialDeviceType,
|
||||||
|
credentialBackedUp,
|
||||||
|
} = registrationInfo
|
||||||
|
|
||||||
|
// Create new Passkey
|
||||||
|
const store: Passkey = {
|
||||||
|
user: user.uuid,
|
||||||
|
webAuthnUserID: options.user.id,
|
||||||
|
id: credentialID,
|
||||||
|
publicKey: encodeBase64(credentialPublicKey),
|
||||||
|
counter,
|
||||||
|
deviceType: credentialDeviceType,
|
||||||
|
backedUp: credentialBackedUp,
|
||||||
|
transports: registration.response.transports,
|
||||||
|
}
|
||||||
|
|
||||||
|
// create and save new Credentials
|
||||||
|
const credential = Credential.load({ name, category: 'passkey', store })
|
||||||
|
await db.ressource.credential.set([credential])
|
||||||
|
|
||||||
|
// Update user credentials
|
||||||
|
// Ensure latest user datas
|
||||||
|
const dbUser = await db.ressource.user.get(user)
|
||||||
|
// Append new credentials
|
||||||
|
const credentials = [...dbUser.credentials, credential.toRef()]
|
||||||
|
const updatedUser = user.update({ credentials })
|
||||||
|
// Save user to db
|
||||||
|
await db.ressource.user.set([updatedUser])
|
||||||
|
// Update session
|
||||||
|
ctx.state.session.set('user', updatedUser)
|
||||||
|
|
||||||
|
const { verified } = verification
|
||||||
|
|
||||||
|
return respondApi('success', { verified })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return respondApi('error', error, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return respondApi('error', new Error('unknown step'), 400)
|
||||||
|
},
|
||||||
|
}
|
27
routes/api/webpush/subscription.ts
Normal file
27
routes/api/webpush/subscription.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { Handlers } from '$fresh/server.ts'
|
||||||
|
import { respondApi } from '../../../src/utils.ts'
|
||||||
|
|
||||||
|
export const handler: Handlers = {
|
||||||
|
async POST(request: Request) {
|
||||||
|
const subscription = await request.json() as PushSubscriptionJSON
|
||||||
|
saveSubscription(subscription)
|
||||||
|
return respondApi('success', 'ok')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSubscription(subscription: PushSubscriptionJSON) {
|
||||||
|
const itemKey = 'webpush-subscription'
|
||||||
|
const subscriptions = JSON.parse(
|
||||||
|
localStorage.getItem(itemKey) ?? '[]',
|
||||||
|
) as PushSubscriptionJSON[]
|
||||||
|
|
||||||
|
// Prevent duplicate
|
||||||
|
const auth = subscription.keys?.auth
|
||||||
|
if (subscriptions.some((sub) => sub.keys?.auth === auth)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store subscription
|
||||||
|
subscriptions.push(subscription)
|
||||||
|
localStorage.setItem(itemKey, JSON.stringify(subscriptions))
|
||||||
|
}
|
9
routes/api/webpush/vapid.ts
Normal file
9
routes/api/webpush/vapid.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { Handlers } from '$fresh/server.ts'
|
||||||
|
import { respondApi } from '../../../src/utils.ts'
|
||||||
|
import { publicKey } from '../../../src/webpush/mod.ts'
|
||||||
|
|
||||||
|
export const handler: Handlers = {
|
||||||
|
GET() {
|
||||||
|
return respondApi('success', publicKey)
|
||||||
|
},
|
||||||
|
}
|
24
routes/apps/index.tsx
Normal file
24
routes/apps/index.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
export default function App() {
|
||||||
|
//TODO implement route
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Filters ... - os [] [] [] - cohabit []
|
||||||
|
</p>
|
||||||
|
<h1>Apps - pour/de cohabit</h1>
|
||||||
|
<h2>Auto hébergées</h2>
|
||||||
|
<h3>Git</h3>
|
||||||
|
<h3>Redmine</h3>
|
||||||
|
<h3>PenPot</h3>
|
||||||
|
<h2>En ligne</h2>
|
||||||
|
<h3>Matrix</h3>
|
||||||
|
<h3>Peertube</h3>
|
||||||
|
<h2>En local/À installer</h2>
|
||||||
|
<h3>Element (lin, and, web, win, ...)</h3>
|
||||||
|
<h3>GitNext (and)</h3>
|
||||||
|
<h1>Apps coup de 💖</h1>
|
||||||
|
<h3>F-Droid</h3>
|
||||||
|
<section></section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
13
routes/docs/index.tsx
Normal file
13
routes/docs/index.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
export default function Doc() {
|
||||||
|
//TODO implement route
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Docs</h1>
|
||||||
|
<h2>Tutoriels</h2>
|
||||||
|
- video 0 - text 0 ...
|
||||||
|
<h2>CheatSheet</h2>
|
||||||
|
- vim
|
||||||
|
<section></section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +1,31 @@
|
||||||
export default function Profil() {
|
import { Button } from 'univoq'
|
||||||
|
import LoginForm from '../../islands/LoginForm.tsx'
|
||||||
|
import PassKeyRegister from '../../islands/PassKeyRegister.tsx'
|
||||||
|
import type { SessionPageProps } from '../../src/session/mod.ts'
|
||||||
|
import type { User } from '@cohabit/ressources_manager/mod.ts'
|
||||||
|
|
||||||
|
export default function Profil({ state }: SessionPageProps) {
|
||||||
|
const user = state.session?.get<User>('user')
|
||||||
|
|
||||||
|
if (user) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>Profil</h1>
|
<h1>Mon compte</h1>
|
||||||
<section></section>
|
<section>
|
||||||
|
<pre>{ JSON.stringify(user, null, 2) }</pre>
|
||||||
|
</section>
|
||||||
|
<div>
|
||||||
|
<PassKeyRegister />
|
||||||
|
<Button label='Disconnect' variant='primary'>Disconnect</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Accéder à mon compte</h1>
|
||||||
|
<LoginForm />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
78
scripts/add_package.ts
Normal file
78
scripts/add_package.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { ensureDir } from '$std/fs/mod.ts'
|
||||||
|
import { join } from '$std/path/join.ts'
|
||||||
|
import denoJson from '../deno.json' with { type: 'json' }
|
||||||
|
|
||||||
|
// Get package from cli
|
||||||
|
const baseRepo = 'https://git.cohabit.fr/'
|
||||||
|
const packageRepoAndTag = Deno.args[0]
|
||||||
|
const match = packageRepoAndTag.match(/(?<name>@\S+\/\S+)@(?<tag>\d\.\d\.\d.*)/)
|
||||||
|
|
||||||
|
// Parse name and version tag
|
||||||
|
if (match === null || match.groups === undefined) {
|
||||||
|
throw new SyntaxError('usage: deno task $task_name @scope/name@x.x.x')
|
||||||
|
}
|
||||||
|
const { name, tag } = match.groups
|
||||||
|
const repoUrl = new URL(`${name.slice(1)}.git`, baseRepo)
|
||||||
|
|
||||||
|
// Setup destination and remove old versions
|
||||||
|
const fsName = name.replace('/', '__')
|
||||||
|
const packageBase = join(Deno.cwd(), 'packages')
|
||||||
|
const destinationBase = join(packageBase, fsName)
|
||||||
|
const destinationCurrent = `${destinationBase}@${tag}`
|
||||||
|
|
||||||
|
// Create base dir if needed
|
||||||
|
await ensureDir(packageBase)
|
||||||
|
|
||||||
|
// Remove old versions
|
||||||
|
for await (const entry of Deno.readDir(packageBase)) {
|
||||||
|
if (entry.isDirectory && entry.name.startsWith(fsName)) {
|
||||||
|
Deno.remove(join(packageBase, entry.name), {
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone repo @ tag
|
||||||
|
const git = new Deno.Command('git', {
|
||||||
|
args: [
|
||||||
|
'clone',
|
||||||
|
repoUrl.href,
|
||||||
|
'--branch',
|
||||||
|
tag,
|
||||||
|
'--single-branch',
|
||||||
|
'--depth',
|
||||||
|
'1',
|
||||||
|
destinationCurrent,
|
||||||
|
],
|
||||||
|
stderr: 'inherit',
|
||||||
|
stdout: 'inherit',
|
||||||
|
})
|
||||||
|
await git.output()
|
||||||
|
|
||||||
|
// Get workspaces
|
||||||
|
// @ts-ignore maybe undefined
|
||||||
|
const workspaces: string[] = denoJson['workspaces'] ?? []
|
||||||
|
// Update deno.json
|
||||||
|
const newDenoJson = {
|
||||||
|
...denoJson,
|
||||||
|
imports: {
|
||||||
|
...denoJson.imports,
|
||||||
|
[`${name}/`]: `./packages/${fsName}@${tag}/`,
|
||||||
|
},
|
||||||
|
workspaces: [
|
||||||
|
...workspaces.filter((workspace) =>
|
||||||
|
workspace.startsWith(`packages/${fsName}`) === false
|
||||||
|
),
|
||||||
|
`packages/${fsName}@${tag}`,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update deno.json file
|
||||||
|
await Deno.writeTextFile('./deno.json', JSON.stringify(newDenoJson))
|
||||||
|
|
||||||
|
const denoFmt = new Deno.Command('deno', {
|
||||||
|
args: ['fmt', 'deno.json'],
|
||||||
|
stderr: 'inherit',
|
||||||
|
stdout: 'inherit',
|
||||||
|
})
|
||||||
|
await denoFmt.output()
|
14
src/db/mock/groups.json
Normal file
14
src/db/mock/groups.json
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "group",
|
||||||
|
"name": "user.admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "group",
|
||||||
|
"name": "user.member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "group",
|
||||||
|
"name": "user.devops"
|
||||||
|
}
|
||||||
|
]
|
68
src/db/mock/users.json
Normal file
68
src/db/mock/users.json
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"firstname": "Jean-Baptiste",
|
||||||
|
"lastname": "Bonnemaison",
|
||||||
|
"mail": "jean-baptiste.bonnemaison@u-bordeaux.fr",
|
||||||
|
"groups": [
|
||||||
|
"user.admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"firstname": "Pierre",
|
||||||
|
"lastname": "Grange-Praderas",
|
||||||
|
"mail": "pierre.grange-praderas@u-bordeaux.fr",
|
||||||
|
"groups": [
|
||||||
|
"user.admin",
|
||||||
|
"user.devops"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"firstname": "Eléonore",
|
||||||
|
"lastname": "Fraisse",
|
||||||
|
"mail": "eleonore.fraisse@icloud.com",
|
||||||
|
"groups": [
|
||||||
|
"user.member"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"firstname": "pgp",
|
||||||
|
"lastname": "pgp",
|
||||||
|
"mail": "pgp@cplusmoins.org",
|
||||||
|
"groups": [
|
||||||
|
"user.admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"firstname": "Julien",
|
||||||
|
"lastname": "Oculi",
|
||||||
|
"mail": "oculi.julien.t@gmail.com",
|
||||||
|
"groups": [
|
||||||
|
"user.admin",
|
||||||
|
"user.devops"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"firstname": "John",
|
||||||
|
"lastname": "Doe",
|
||||||
|
"mail": "test_coha@yopmail.com",
|
||||||
|
"groups": [
|
||||||
|
"user.admin",
|
||||||
|
"user.devops"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"firstname": "Ulrich",
|
||||||
|
"lastname": "Moniteur",
|
||||||
|
"mail": "moniteurulrich@gmail.com",
|
||||||
|
"groups": [
|
||||||
|
"user.member"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
42
src/db/mod.ts
Normal file
42
src/db/mod.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { Db, Group } from '@cohabit/ressources_manager/mod.ts'
|
||||||
|
|
||||||
|
// Import Datas
|
||||||
|
import groups from './mock/groups.json' with { type: 'json' }
|
||||||
|
import users from './mock/users.json' with { type: 'json' }
|
||||||
|
import { User } from '@cohabit/ressources_manager/src/models/mod.ts'
|
||||||
|
import { MailAddress } from '@cohabit/ressources_manager/types.ts'
|
||||||
|
import { existsSync } from '$std/fs/exists.ts'
|
||||||
|
|
||||||
|
const dbPath = './_fresh/db.sqlite'
|
||||||
|
export const db = await Db.init(dbPath)
|
||||||
|
|
||||||
|
|
||||||
|
//! Temp for demo only
|
||||||
|
if (!sessionStorage.getItem('db-loaded') && !existsSync(dbPath)) {
|
||||||
|
console.log('=============LOAD DB=============')
|
||||||
|
|
||||||
|
// Load groups to DB
|
||||||
|
for (const { name } of groups) {
|
||||||
|
const group = Group.load({ name })
|
||||||
|
db.ressource.group.set([group])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load users to DB
|
||||||
|
for (const { lastname, firstname, mail, groups: groupNames } of users) {
|
||||||
|
// Get groups of user
|
||||||
|
const groups = await db.ressource.group.listRef((group) =>
|
||||||
|
groupNames.includes(group.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = User.load({
|
||||||
|
firstname,
|
||||||
|
lastname,
|
||||||
|
mail: mail as MailAddress,
|
||||||
|
groups,
|
||||||
|
})
|
||||||
|
|
||||||
|
db.ressource.user.set([user])
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem('db-loaded', 'true')
|
||||||
|
}
|
43
src/serviceworker/mod.ts
Normal file
43
src/serviceworker/mod.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
/// <reference no-default-lib="true"/>
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
// Force load service worker types
|
||||||
|
const self = globalThis as unknown as ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
const cacheName = 'v1' //TODO dynamique cache key
|
||||||
|
const _preCachedPaths = ['/', '/css/*', '/assets/*'] //TODO pre-cache these paths
|
||||||
|
|
||||||
|
export function main() {
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
//TODO handle installation
|
||||||
|
event.waitUntil(
|
||||||
|
addToCache([]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', () => {
|
||||||
|
//TODO handle activation
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (_event) => {
|
||||||
|
//TODO add fetch strategies
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const { title, options } = (event.data?.json() ?? {}) as {
|
||||||
|
title?: string
|
||||||
|
options?: Partial<NotificationOptions>
|
||||||
|
}
|
||||||
|
if (title) {
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, options),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function addToCache(ressources: string[]) {
|
||||||
|
const cache = await caches.open(cacheName) //TODO dynamique cache key
|
||||||
|
await cache.addAll(ressources)
|
||||||
|
|
||||||
|
//TODO list statics
|
||||||
|
}
|
74
src/serviceworker/src/strategy.ts
Normal file
74
src/serviceworker/src/strategy.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
export class Strategy {
|
||||||
|
#cache: Cache
|
||||||
|
|
||||||
|
constructor(cache: Cache) {
|
||||||
|
this.#cache = cache
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheFirst({ request, preloadResponse }: FetchEvent) {
|
||||||
|
const fetchRequest = async () => {
|
||||||
|
const response = await fetch(request)
|
||||||
|
if (response.ok) {
|
||||||
|
this.#cache.put(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get navigator preload request
|
||||||
|
preloadResponse
|
||||||
|
.then((preload: Response | undefined) => {
|
||||||
|
if (preload?.ok) {
|
||||||
|
return this.#cache.put(request, preload)
|
||||||
|
}
|
||||||
|
throw new Error()
|
||||||
|
})
|
||||||
|
// Else fetch request
|
||||||
|
.catch(fetchRequest)
|
||||||
|
|
||||||
|
return this.#cache.match(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async networkFirst(
|
||||||
|
{ request, preloadResponse }: FetchEvent,
|
||||||
|
{ fallbackUrl, timeout }: { fallbackUrl?: string; timeout?: number },
|
||||||
|
) {
|
||||||
|
const signal = timeout ? AbortSignal.timeout(timeout) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get navigator preload (see: https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/preloadResponse)
|
||||||
|
const preload = await getPreload(preloadResponse)
|
||||||
|
if (preload?.ok) {
|
||||||
|
this.#cache.put(request, preload.clone())
|
||||||
|
return preload
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
// Else fetch request
|
||||||
|
const response = await fetch(request, { signal })
|
||||||
|
if (response.ok) {
|
||||||
|
this.#cache.put(request, response.clone())
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Else return fallback or cached response
|
||||||
|
return this.#cache.match(fallbackUrl ?? request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreload(
|
||||||
|
preloadResponse: FetchEvent['preloadResponse'],
|
||||||
|
ac?: { signal?: AbortSignal },
|
||||||
|
): Promise<Response | undefined> {
|
||||||
|
const { promise, resolve, reject } = Promise.withResolvers<
|
||||||
|
Response | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
|
ac?.signal?.addEventListener('abort', () => {
|
||||||
|
reject(new Error('aborted by signal'))
|
||||||
|
})
|
||||||
|
|
||||||
|
resolve(preloadResponse)
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
158
src/session/mod.ts
Normal file
158
src/session/mod.ts
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
import type { Handlers, PageProps } from '$fresh/server.ts'
|
||||||
|
import { getCookies } from '@std/http/cookie'
|
||||||
|
|
||||||
|
type SessionEntry<T = unknown> = {
|
||||||
|
accessCount: number
|
||||||
|
flash: boolean
|
||||||
|
value: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
session: Session
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPageProps<T = unknown, S = Record<string, unknown>> =
|
||||||
|
PageProps<T, S & SessionState>
|
||||||
|
|
||||||
|
export type SessionHandlers<T = unknown, S = Record<string, unknown>> =
|
||||||
|
Handlers<T, S & SessionState>
|
||||||
|
|
||||||
|
export class SessionStore {
|
||||||
|
static #store = new Map<string, Session>()
|
||||||
|
|
||||||
|
static get maxAge() {
|
||||||
|
const halfHour = 30 * 60 * 1_000
|
||||||
|
return Date.now() + halfHour
|
||||||
|
}
|
||||||
|
static createSession(): Session {
|
||||||
|
return new Session(this.#store)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSession(uuid: string): Session | undefined {
|
||||||
|
// Check session validity
|
||||||
|
const halfHour = 30 * 60 * 1_000
|
||||||
|
const maxOld = Date.now() - halfHour
|
||||||
|
const session = this.#store.get(uuid)
|
||||||
|
|
||||||
|
if (session === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.timestamp < maxOld) {
|
||||||
|
session.destroy()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFromRequest(request: Request): Session | undefined {
|
||||||
|
const sessionId = getCookies(request.headers)['_SESSION'] ?? ''
|
||||||
|
return this.getSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Session {
|
||||||
|
#db = new Map<string, SessionEntry>()
|
||||||
|
#timestamp = Date.now()
|
||||||
|
#uuid = crypto.randomUUID()
|
||||||
|
#store: Map<string, Session>
|
||||||
|
|
||||||
|
constructor(store: Map<string, Session>) {
|
||||||
|
this.#store = store
|
||||||
|
store.set(this.#uuid, this)
|
||||||
|
|
||||||
|
//cleanup old sessions
|
||||||
|
const halfHour = 30 * 60 * 1_000
|
||||||
|
const maxAge = this.#timestamp - halfHour
|
||||||
|
|
||||||
|
for (const [uuid, session] of store.entries()) {
|
||||||
|
if (session.#timestamp < maxAge) {
|
||||||
|
store.delete(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateTimestamp() {
|
||||||
|
this.#timestamp = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
get uuid() {
|
||||||
|
return this.#uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
get timestamp() {
|
||||||
|
return this.#timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T = unknown>(key: string): T | undefined {
|
||||||
|
this.#updateTimestamp()
|
||||||
|
|
||||||
|
const entry = this.#db.get(key)
|
||||||
|
|
||||||
|
// no entry
|
||||||
|
if (entry === undefined) return
|
||||||
|
|
||||||
|
// flash entry
|
||||||
|
if (entry.flash) {
|
||||||
|
this.#db.delete(key)
|
||||||
|
return entry.value as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// normal entry
|
||||||
|
entry.accessCount++
|
||||||
|
this.#db.set(key, entry)
|
||||||
|
return entry.value as T
|
||||||
|
}
|
||||||
|
set<T = unknown>(key: string, value: T): void {
|
||||||
|
this.#updateTimestamp()
|
||||||
|
|
||||||
|
const entry = this.#db.get(key) as SessionEntry<T> | undefined
|
||||||
|
|
||||||
|
// update or create
|
||||||
|
const newEntry: SessionEntry<T> = {
|
||||||
|
accessCount: entry?.accessCount ? entry.accessCount++ : 0,
|
||||||
|
flash: entry?.flash ?? false,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
this.#db.set(key, newEntry)
|
||||||
|
}
|
||||||
|
delete(key: string): boolean {
|
||||||
|
this.#updateTimestamp()
|
||||||
|
|
||||||
|
return this.#db.delete(key)
|
||||||
|
}
|
||||||
|
has(key: string): boolean {
|
||||||
|
this.#updateTimestamp()
|
||||||
|
|
||||||
|
return this.#db.has(key)
|
||||||
|
}
|
||||||
|
list(): [key: string, value: unknown][] {
|
||||||
|
this.#updateTimestamp()
|
||||||
|
|
||||||
|
const keys = [...this.#db.keys()]
|
||||||
|
return keys.map((key) => ([key, this.get(key)] as [string, unknown]))
|
||||||
|
}
|
||||||
|
flash<T = unknown>(key: string, value: T) {
|
||||||
|
this.#updateTimestamp()
|
||||||
|
|
||||||
|
const entry = this.#db.get(key) as SessionEntry<T> | undefined
|
||||||
|
|
||||||
|
// update or create
|
||||||
|
const newEntry: SessionEntry<T> = {
|
||||||
|
accessCount: entry?.accessCount ? entry.accessCount++ : 0,
|
||||||
|
flash: true,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
this.#db.set(key, newEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.#db.clear()
|
||||||
|
this.#store.delete(this.uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Session }
|
|
@ -1,9 +1,37 @@
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Adjusted Lucida Bright Fallback';
|
||||||
|
src: local(Lucida Bright);
|
||||||
|
size-adjust: 113%;
|
||||||
|
ascent-override: normal;
|
||||||
|
descent-override: 13%;
|
||||||
|
line-gap-override: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Adjusted Verdana Fallback';
|
||||||
|
src: local(Verdana);
|
||||||
|
size-adjust: 93.5%;
|
||||||
|
ascent-override: 123%;
|
||||||
|
descent-override: 48%;
|
||||||
|
line-gap-override: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Adjusted Courier New Fallback';
|
||||||
|
src: local(Courier New);
|
||||||
|
size-adjust: 100%;
|
||||||
|
ascent-override: 93%;
|
||||||
|
descent-override: 37%;
|
||||||
|
line-gap-override: normal;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* font */
|
/* font */
|
||||||
--_font-size: var(--font-size-1);
|
--_font-size: var(--font-size-1);
|
||||||
--_font-family: 'MuseoModerno', sans-serif;
|
--_font-family: 'MuseoModerno', 'Adjusted Verdana Fallback', sans-serif;
|
||||||
--_font-family-accent: 'Hepta Slab', serif;
|
--_font-family-accent: 'Hepta Slab', 'Adjusted Lucida Bright Fallback',
|
||||||
--_font-family-code: 'Fira Code', monospace;
|
serif;
|
||||||
|
--_font-family-code: 'Fira Code', 'Adjusted Courier New Fallback', monospace;
|
||||||
--_font-color: var(--choco-12);
|
--_font-color: var(--choco-12);
|
||||||
|
|
||||||
/* border */
|
/* border */
|
||||||
|
@ -77,14 +105,42 @@ h1 {
|
||||||
margin-block: var(--_gap);
|
margin-block: var(--_gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta {
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--_gap-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
transition: all var(--_transition-delay) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not([type='radio']):not([type='checkbox']) {
|
||||||
|
padding: var(--_gap-half);
|
||||||
|
border: var(--_border-size) solid var(--_translucent);
|
||||||
|
background-color: var(--_translucent);
|
||||||
|
color: hsl(from var(--_font-color) h s l / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not([type='radio']):not([type='checkbox']):focus {
|
||||||
|
background-color: var(--_background-color);
|
||||||
|
color: var(--_font-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='radio'],
|
||||||
|
input[type='checkbox'] {
|
||||||
|
width: calc(1.5 * var(--_gap));
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta,
|
||||||
|
.button {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background-color: var(--_accent-color);
|
background-color: var(--_accent-color);
|
||||||
color: var(--sand-0);
|
color: var(--sand-0);
|
||||||
width: fit-content;
|
width: 100%;
|
||||||
|
font-size: 120%;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
padding: 1rem 2rem;
|
padding: var(--_gap-half);
|
||||||
font-size: 150%;
|
|
||||||
font-family: var(--_font-family-accent);
|
font-family: var(--_font-family-accent);
|
||||||
border: var(--_border-size) solid var(--_accent-color);
|
border: var(--_border-size) solid var(--_accent-color);
|
||||||
transition: all var(--_transition-delay) ease;
|
transition: all var(--_transition-delay) ease;
|
||||||
|
@ -93,7 +149,9 @@ h1 {
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta:hover,
|
.cta:hover,
|
||||||
.cta:focus-visible {
|
.cta:focus-visible,
|
||||||
|
.button:hover,
|
||||||
|
.button:focus-visible {
|
||||||
background-color: var(--lime-1);
|
background-color: var(--lime-1);
|
||||||
color: var(--_accent-color);
|
color: var(--_accent-color);
|
||||||
box-shadow: 0 0 0 0 var(--_accent-color);
|
box-shadow: 0 0 0 0 var(--_accent-color);
|
||||||
|
@ -102,3 +160,9 @@ h1 {
|
||||||
.cta:active {
|
.cta:active {
|
||||||
box-shadow: 0 0 180px 20px var(--_accent-color);
|
box-shadow: 0 0 180px 20px var(--_accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 150%;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
|
@ -12,3 +12,4 @@
|
||||||
@import url('../../islands/SearchBox.css');
|
@import url('../../islands/SearchBox.css');
|
||||||
@import url('../../islands/MoreBox.css');
|
@import url('../../islands/MoreBox.css');
|
||||||
@import url('../../islands/AiChatBox.css');
|
@import url('../../islands/AiChatBox.css');
|
||||||
|
@import url('../../islands/LoginForm.css');
|
||||||
|
|
79
src/utils.ts
Normal file
79
src/utils.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { JsonValue } from '$std/json/common.ts'
|
||||||
|
|
||||||
|
export type JsonCompatible = JsonValue | { toJSON(): JsonValue } | unknown
|
||||||
|
|
||||||
|
export function respondApi<
|
||||||
|
Kind extends ApiPayload['kind'],
|
||||||
|
Payload extends JsonCompatible,
|
||||||
|
>(
|
||||||
|
kind: Kind,
|
||||||
|
payload?: Payload,
|
||||||
|
status?: number,
|
||||||
|
statusText?: string,
|
||||||
|
): Response {
|
||||||
|
if (kind === 'error') {
|
||||||
|
return Response.json({
|
||||||
|
kind: 'error',
|
||||||
|
error: String(payload ?? ''),
|
||||||
|
} as ApiPayload, {
|
||||||
|
status: status ?? 500,
|
||||||
|
statusText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
kind: 'success',
|
||||||
|
data: payload ?? null,
|
||||||
|
} as ApiPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestApi<
|
||||||
|
Payload extends JsonCompatible | undefined,
|
||||||
|
ApiResponse extends JsonCompatible,
|
||||||
|
>(
|
||||||
|
route: string,
|
||||||
|
method: 'GET' | 'POST' | 'DELETE' | 'PATCH',
|
||||||
|
payload?: Payload | null,
|
||||||
|
): Promise<ApiResponse> {
|
||||||
|
const csrf = getCookie('_CSRF') ?? ''
|
||||||
|
|
||||||
|
const base = new URL('/api/', location.origin)
|
||||||
|
const endpoint = new URL(
|
||||||
|
route.startsWith('/') ? `.${route}` : route,
|
||||||
|
base.href,
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'X-CSRF-TOKEN': csrf,
|
||||||
|
},
|
||||||
|
body: payload ? JSON.stringify(payload) : null,
|
||||||
|
})
|
||||||
|
const apiPayload = await response.json() as ApiPayload<ApiResponse>
|
||||||
|
|
||||||
|
if (apiPayload.kind === 'error') {
|
||||||
|
throw new Error(`api request error while getting "${endpoint.href}"`, {
|
||||||
|
cause: apiPayload.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiPayload.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiPayload<ApiResponse extends JsonCompatible = never> = {
|
||||||
|
kind: 'success'
|
||||||
|
data: ApiResponse
|
||||||
|
} | {
|
||||||
|
kind: 'error'
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name: string): string | undefined {
|
||||||
|
const cookiesEntries = document.cookie.split(';').map((cookie) =>
|
||||||
|
cookie.trim().split('=')
|
||||||
|
)
|
||||||
|
const cookies = Object.fromEntries(cookiesEntries)
|
||||||
|
return cookies[name]
|
||||||
|
}
|
23
src/webauthn/mod.ts
Normal file
23
src/webauthn/mod.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
export function getRelyingParty(url: string | URL) {
|
||||||
|
url = new URL(url)
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Human-readable title for your website
|
||||||
|
*/
|
||||||
|
name: 'Coh@bit',
|
||||||
|
/**
|
||||||
|
* A unique identifier for your website. 'localhost' is okay for
|
||||||
|
* local dev
|
||||||
|
*/
|
||||||
|
// const rpID = 'cohabit.fr'
|
||||||
|
id: url.hostname,
|
||||||
|
/**
|
||||||
|
* The URL at which registrations and authentications should occur.
|
||||||
|
* 'http://localhost' and 'http://localhost:PORT' are also valid.
|
||||||
|
* Do NOT include any trailing /
|
||||||
|
*/
|
||||||
|
// const origin = `https://${rpID}`
|
||||||
|
origin: url.origin,
|
||||||
|
}
|
||||||
|
}
|
45
src/webpush/mod.ts
Normal file
45
src/webpush/mod.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
//@deno-types="npm:@types/web-push@^3.6.3"
|
||||||
|
import webpush from 'web-push'
|
||||||
|
|
||||||
|
// DEV mode
|
||||||
|
// localStorage.clear()
|
||||||
|
|
||||||
|
const vapidKeys = getVapidKeys()
|
||||||
|
export const { publicKey } = vapidKeys
|
||||||
|
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
'mailto:contact@example.com',
|
||||||
|
vapidKeys.publicKey,
|
||||||
|
vapidKeys.privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
// const itemKey = 'webpush-subscription'
|
||||||
|
// setInterval(async () => {
|
||||||
|
// const subscriptions = JSON.parse(localStorage.getItem(itemKey) ?? '[]') as PushSubscriptionJSON[]
|
||||||
|
|
||||||
|
// for (const subscription of subscriptions) {
|
||||||
|
// try {
|
||||||
|
// // console.log('PUSH NOTIFICATION', subscription)
|
||||||
|
// const payload: { title: string, options?: Partial<NotificationOptions> } = { title: `This is a test @ ${new Date().toLocaleTimeString()}` }
|
||||||
|
// //@ts-ignore TODO check endpoint is defined
|
||||||
|
// const a = await webpush.sendNotification(subscription, JSON.stringify(payload))
|
||||||
|
// console.log(a)
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error(error)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }, 10_000)
|
||||||
|
|
||||||
|
function getVapidKeys(): webpush.VapidKeys {
|
||||||
|
const itemKey = 'webpush-vapid-keys'
|
||||||
|
const item = localStorage.getItem(itemKey)
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
const vapidKeys = JSON.parse(item) as webpush.VapidKeys
|
||||||
|
return vapidKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
const vapidKeys = webpush.generateVAPIDKeys()
|
||||||
|
localStorage.setItem(itemKey, JSON.stringify(vapidKeys))
|
||||||
|
return vapidKeys
|
||||||
|
}
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 594 KiB After Width: | Height: | Size: 594 KiB |
Loading…
Reference in a new issue