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": {
|
||||
"*.tsx": "${capture}.*"
|
||||
},
|
||||
"cSpell.words": ["technoshop", "Technoshop", "univoq"],
|
||||
"cSpell.words": [
|
||||
"magiclink",
|
||||
"RPID",
|
||||
"simplewebauthn",
|
||||
"startserviceworker",
|
||||
"technoshop",
|
||||
"Technoshop",
|
||||
"univoq"
|
||||
],
|
||||
"cssvar.enable": true,
|
||||
"cssvar.files": ["./_fresh/*"],
|
||||
"conventionalCommits.scopes": ["css", "config", "ui", "pwa"],
|
||||
"conventionalCommits.scopes": [
|
||||
"css",
|
||||
"config",
|
||||
"ui",
|
||||
"pwa",
|
||||
"api",
|
||||
"ux",
|
||||
"route"
|
||||
],
|
||||
"[ignore]": {
|
||||
"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",
|
||||
"preview": "deno run -A main.ts",
|
||||
"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": {
|
||||
"singleQuote": true,
|
||||
"semiColons": false,
|
||||
"useTabs": true
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": [
|
||||
"fresh",
|
||||
"recommended"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"**/_fresh/*"
|
||||
],
|
||||
"fmt": { "singleQuote": true, "semiColons": false, "useTabs": true },
|
||||
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
|
||||
"exclude": ["**/_fresh/*", "packages/"],
|
||||
"imports": {
|
||||
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||
"preact": "https://esm.sh/preact@10.19.6",
|
||||
"preact/": "https://esm.sh/preact@10.19.6/",
|
||||
"$std/": "https://deno.land/std@0.208.0/",
|
||||
"@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-core": "https://esm.sh/*@preact/signals-core@1.5.1",
|
||||
"$std/": "https://deno.land/std@0.208.0/",
|
||||
"univoq": "https://deno.land/x/univoq@0.2.0/mod.ts",
|
||||
"@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0",
|
||||
"@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/",
|
||||
"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": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
}
|
||||
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
|
||||
"workspaces": [
|
||||
"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 { cssBundler } from 'css_bundler'
|
||||
import { cssBundler } from '@jotsr/smart-css-bundler/fresh'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cssBundler(
|
||||
import.meta.resolve('./src/stylesheets'),
|
||||
import.meta.resolve('./cache'),
|
||||
{ logLevel: 'error' },
|
||||
),
|
||||
cssBundler(['./src/stylesheets/main.css'], { bundleSubDir: 'css' }),
|
||||
],
|
||||
})
|
||||
|
|
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 {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
border: var(--_border-size) solid transparent;
|
||||
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) {
|
||||
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 <></>
|
||||
|
|
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 { Footer } from '../components/Footer.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) {
|
||||
return (
|
||||
|
@ -53,7 +53,7 @@ export default function App({ Component }: PageProps) {
|
|||
<Component />
|
||||
</Partial>
|
||||
</main>
|
||||
<RegisterServiceWorker href={asset('/sw.js')} />
|
||||
<ProgressiveWebApp />
|
||||
<Footer />
|
||||
</body>
|
||||
</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 (
|
||||
<>
|
||||
<h1>Profil</h1>
|
||||
<section></section>
|
||||
<h1>Mon compte</h1>
|
||||
<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 {
|
||||
/* font */
|
||||
--_font-size: var(--font-size-1);
|
||||
--_font-family: 'MuseoModerno', sans-serif;
|
||||
--_font-family-accent: 'Hepta Slab', serif;
|
||||
--_font-family-code: 'Fira Code', monospace;
|
||||
--_font-family: 'MuseoModerno', 'Adjusted Verdana Fallback', sans-serif;
|
||||
--_font-family-accent: 'Hepta Slab', 'Adjusted Lucida Bright Fallback',
|
||||
serif;
|
||||
--_font-family-code: 'Fira Code', 'Adjusted Courier New Fallback', monospace;
|
||||
--_font-color: var(--choco-12);
|
||||
|
||||
/* border */
|
||||
|
@ -77,14 +105,42 @@ h1 {
|
|||
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;
|
||||
background-color: var(--_accent-color);
|
||||
color: var(--sand-0);
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
font-size: 120%;
|
||||
height: fit-content;
|
||||
padding: 1rem 2rem;
|
||||
font-size: 150%;
|
||||
padding: var(--_gap-half);
|
||||
font-family: var(--_font-family-accent);
|
||||
border: var(--_border-size) solid var(--_accent-color);
|
||||
transition: all var(--_transition-delay) ease;
|
||||
|
@ -93,7 +149,9 @@ h1 {
|
|||
}
|
||||
|
||||
.cta:hover,
|
||||
.cta:focus-visible {
|
||||
.cta:focus-visible,
|
||||
.button:hover,
|
||||
.button:focus-visible {
|
||||
background-color: var(--lime-1);
|
||||
color: var(--_accent-color);
|
||||
box-shadow: 0 0 0 0 var(--_accent-color);
|
||||
|
@ -102,3 +160,9 @@ h1 {
|
|||
.cta:active {
|
||||
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/MoreBox.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