Compare commits

..

46 commits

Author SHA1 Message Date
Julien Oculi 6134e9a6ec feat(route): add new doc and apps routes 2024-06-19 16:11:53 +02:00
Julien Oculi c766a00980 feat: 🤡 add demo mock for db 2024-06-19 16:10:22 +02:00
Julien Oculi 80a2eed2ee feat(api): implement passkey login and register api 2024-06-19 16:09:45 +02:00
Julien Oculi 8d316ae52e feat(api): implement magic link api hook 2024-06-19 10:37:10 +02:00
Julien Oculi b01bbfdb5b feat(ux): allow user to name passkey at register 2024-06-19 10:34:43 +02:00
Julien Oculi ee69d545b5 feat(css): 💄 update global css 2024-06-19 10:33:31 +02:00
Julien Oculi 91eefc520f refactor(ui): ♻️ update demo profil page 2024-06-19 10:26:48 +02:00
Julien Oculi db07adbe22 feat(css): add styles for login form 2024-06-19 10:23:30 +02:00
Julien Oculi 9ce155570d feat(ux): allow user to deactivate passkey for login 2024-06-19 10:23:02 +02:00
Julien Oculi 6f5d2c1535 fix(css): ✏️ duplicated attribute in css definition 2024-06-18 18:02:48 +02:00
Julien Oculi e590767b29 feat: add login form and passkey register form 2024-06-17 13:26:27 +02:00
Julien Oculi 09bab92bca chore(config): 🔧 exclude packages/* from deno.json 2024-06-17 13:21:08 +02:00
Julien Oculi fb7871ea21 fix(config): 📦 update deno.json to fit imported packages 2024-06-17 13:07:05 +02:00
Julien Oculi c74687ca64 feat: 🏷️ add new SessionHandlers type helper 2024-06-17 12:46:46 +02:00
Julien Oculi 021d9e689b refactor: ♻️ remove unnecessary unknown extends in generic 2024-06-17 12:43:46 +02:00
Julien Oculi e44401415a build: 🔒 narrow dev:add_package script deno net permissions 2024-06-15 16:24:29 +02:00
Julien Oculi e9b3437775 build: 🐛 change local package fs name format 2024-06-15 16:23:20 +02:00
Julien Oculi deee33056c build: 🐛 fix relative path targeting parent directory 2024-06-15 16:20:40 +02:00
Julien Oculi 0c8789435c build: 🐛 fix package name parsing allow any string as name and scope 2024-06-15 16:18:47 +02:00
Julien Oculi 41ba7e2904 build: 👷 add script to add remote deno packages as local deno workspaces 2024-06-15 16:14:05 +02:00
Julien Oculi 0c14e63808 feat: 🏷️ add new utility types for session handling 2024-06-13 23:51:30 +02:00
Julien Oculi c4cd04eb95 feat: pass session state to downstream contexts 2024-06-13 23:50:32 +02:00
Julien Oculi ca482e8956 refactor: ♻️ remove code duplication 2024-06-13 18:40:49 +02:00
Julien Oculi 7ea95c67c4 refactor: ♻️ rewrite cookie and session lifecycle to remove _INSTANCE cookie 2024-06-13 18:38:34 +02:00
Julien Oculi eb13af1ac8 fix: 🐛 force global cookies path 2024-06-13 17:20:15 +02:00
Julien Oculi b032fe2161 fix: 🐛 patch cookie clear process 2024-06-13 14:42:39 +02:00
Julien Oculi c38ae17881 fix: 🐛 enforce global cookies path 2024-06-13 14:38:27 +02:00
Julien Oculi 8dc12c4e0d feat: add cookies auto reset 2024-06-13 13:47:46 +02:00
Julien Oculi 7a6497a5fe chore: 📦 update dependencies 2024-06-13 12:44:58 +02:00
Julien Oculi 0f4c187c26 style: 🎨 deno fmt 2024-06-13 12:43:29 +02:00
Julien Oculi bdbe932872 feat(pwa): register service worker 2024-06-13 12:42:41 +02:00
Julien Oculi 908c820cb0 feat(pwa): add service worker cache strategies 2024-06-13 12:28:38 +02:00
Julien Oculi 61d072cb06 feat(pwa): add service worker module 2024-06-13 12:27:56 +02:00
Julien Oculi 6ae5348e2e feat(api): 🔒 check csrf token for all non get request 2024-06-13 12:25:30 +02:00
Julien Oculi f2348b0177 feat(api): add api communication helpers 2024-06-13 12:23:11 +02:00
Julien Oculi 756c5564b3 feat: 🔒 add csrf checks 2024-06-13 12:20:47 +02:00
Julien Oculi e2c8313aa3 fix: 🐛 positive max age calculation 2024-06-13 12:19:02 +02:00
Julien Oculi a1844176bb refactor: ♻️ expose session max age as static getter 2024-06-13 12:16:23 +02:00
Julien Oculi ca79b4a20d feat: add server sessions 2024-06-13 12:03:07 +02:00
Julien Oculi c971542ec8 feat(pwa): add service worker and registration 2024-06-11 17:02:00 +02:00
Julien Oculi 8d7ad1dc2f fix(pwa): ✏️ typo in static asset dir name 2024-06-11 13:09:44 +02:00
Julien Oculi 5ff284c936 feat: 👷 update css bundler plugin and move bundle dir 2024-06-11 12:36:28 +02:00
Julien Oculi ad296fe1b8 fix: ⬆️ upgrade css bundler plugin 2024-06-11 12:13:56 +02:00
Julien Oculi cc9235d64f chore: ⬆️ update imports 2024-06-10 22:41:53 +02:00
Julien Oculi 5dd9f00599 chore(config): 🔨 change css bundle to @jotsr/smart-css-bundler 2024-06-10 22:41:10 +02:00
Julien Oculi 503d8425c2 feat(css): add fallback font-faces to avoid CLS 2024-06-07 10:25:55 +02:00
35 changed files with 1642 additions and 51 deletions

20
.vscode/settings.json vendored
View file

@ -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"
} }

View file

@ -0,0 +1,5 @@
import RegisterServiceWorker from '../islands/RegisterServiceWorker.tsx'
export function ProgressiveWebApp() {
return <RegisterServiceWorker />
}

View file

@ -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"]
} }

View file

@ -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
View 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
View 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
}

View file

@ -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
View 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
}

View file

@ -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 <></>

View 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
}

View file

@ -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
View 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
View 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()
}

View 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)
}

View 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)
},
}

View 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)
},
}

View 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))
}

View 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
View 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
View 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>
</>
)
}

View file

@ -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>Mon compte</h1>
<section>
<pre>{ JSON.stringify(user, null, 2) }</pre>
</section>
<div>
<PassKeyRegister />
<Button label='Disconnect' variant='primary'>Disconnect</Button>
</div>
</>
)
}
return ( return (
<> <>
<h1>Profil</h1> <h1>Accéder à mon compte</h1>
<section></section> <LoginForm />
</> </>
) )
} }

78
scripts/add_package.ts Normal file
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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 }

View file

@ -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;
}

View file

@ -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
View 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
View 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
View 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
}

View file

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View file

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 594 KiB