fix(island): 🐛 transpilation cause Suspense to broke promise as children

This commit is contained in:
Julien Oculi 2024-07-16 16:34:43 +02:00
parent f24143964f
commit c6eb438314
21 changed files with 457 additions and 456 deletions

View file

@ -13,8 +13,7 @@ export function AutoGrid(
<div <div
class='components__auto_grid' class='components__auto_grid'
style={{ style={{
gridTemplateColumns: gridTemplateColumns: `repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
`repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
...style, ...style,
}} }}
> >

View file

@ -69,9 +69,7 @@ export function BlogPost(
<div class='components__blog_post__infos'> <div class='components__blog_post__infos'>
<span>{`Visibilité : ${options.visibility}`}</span> <span>{`Visibilité : ${options.visibility}`}</span>
<span> <span>
{`Date de délivrance : ${ {`Date de délivrance : ${new Date(options.dueDate).toLocaleString()}`}
new Date(options.dueDate).toLocaleString()
}`}
</span> </span>
</div> </div>
<div class='components__blog_post__description'> <div class='components__blog_post__description'>
@ -90,9 +88,7 @@ export function BlogPost(
function NewsTags({ tags }: Pick<BlogProps, 'tags'>) { function NewsTags({ tags }: Pick<BlogProps, 'tags'>) {
return ( return (
<div class='components__blog_block__tags'> <div class='components__blog_block__tags'>
{tags {tags ? tags.map((tag) => <span>{tag}</span>) : <span>Aucun tag</span>}
? tags.map((tag) => <span>{tag}</span>)
: <span>Aucun tag</span>}
</div> </div>
) )
} }

View file

@ -3,26 +3,26 @@ import { Ensure } from ':types'
import { JSX } from 'preact' import { JSX } from 'preact'
export type PictureProps = export type PictureProps =
& { formats: string[] } & { formats: string[] }
& Ensure<JSX.HTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'loading'> & Ensure<JSX.HTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'loading'>
export function Picture( export function Picture(
{ formats, src, ...props }: PictureProps, { formats, src, ...props }: PictureProps,
) { ) {
const groups = unwrapSignalOrValue(src)?.match(/(?<path>.*)(?<ext>\.\w+)/) const groups = unwrapSignalOrValue(src)?.match(/(?<path>.*)(?<ext>\.\w+)/)
?.groups ?.groups
if (groups === undefined) { if (groups === undefined) {
throw new SyntaxError(`unable to parse path of "${src.valueOf()}"`) throw new SyntaxError(`unable to parse path of "${src.valueOf()}"`)
} }
const { path } = groups const { path } = groups
return ( return (
<picture> <picture>
{formats.map((format) => ( {formats.map((format) => (
<source type={`image/${format}`} srcset={`${path}.${format}`} /> <source type={`image/${format}`} srcset={`${path}.${format}`} />
))} ))}
<img src={src} {...props} /> <img src={src} {...props} />
</picture> </picture>
) )
} }

10
dev.ts
View file

@ -6,9 +6,9 @@ import config from './fresh.config.ts'
import '$std/dotenv/load.ts' import '$std/dotenv/load.ts'
await dev(import.meta.url, './main.ts', { await dev(import.meta.url, './main.ts', {
...config, ...config,
server: { server: {
cert: await Deno.readTextFile('./cert/localhost.pem'), cert: await Deno.readTextFile('./cert/localhost.pem'),
key: await Deno.readTextFile('./cert/localhost-key.pem'), key: await Deno.readTextFile('./cert/localhost-key.pem'),
}, },
}) })

View file

@ -3,56 +3,56 @@ import CardList from ':islands/CardList.tsx'
import type { Ref } from 'preact' import type { Ref } from 'preact'
export default function BlogCardList( export default function BlogCardList(
{ limit, usePlaceholder, useObserver }: { { limit, usePlaceholder, useObserver }: {
limit?: number limit?: number
usePlaceholder?: boolean usePlaceholder?: boolean
useObserver?: boolean useObserver?: boolean
}, },
) { ) {
if (usePlaceholder) { if (usePlaceholder) {
return ( return (
<CardList <CardList
apiRoute='news/fetchAll' apiRoute='news/fetchAll'
builder={builder} builder={builder}
limit={limit} limit={limit}
placeholder={Placeholder} placeholder={Placeholder}
fallback={Fallback} fallback={Fallback}
useObserver={useObserver} useObserver={useObserver}
/> />
) )
} }
return ( return (
<CardList <CardList
apiRoute='news/fetchAll' apiRoute='news/fetchAll'
builder={builder} builder={builder}
limit={limit} limit={limit}
/> />
) )
} }
function builder(news: BlogProps) { function builder(news: BlogProps) {
return BlogCard({ ...news, lastUpdate: new Date(news.lastUpdate) }) return BlogCard({ ...news, lastUpdate: new Date(news.lastUpdate) })
} }
function Placeholder({ ref }: { ref?: Ref<HTMLDivElement> | undefined }) { function Placeholder({ ref }: { ref?: Ref<HTMLDivElement> | undefined }) {
return ( return (
<div <div
class='components__blog_block components__blog_block--card components__blog_block--placeholder' class='components__blog_block components__blog_block--card components__blog_block--placeholder'
ref={ref} ref={ref}
> >
<h3>Chargement ...</h3> <h3>Chargement ...</h3>
</div> </div>
) )
} }
function Fallback() { function Fallback() {
return ( return (
<div <div
class='components__blog_block components__blog_block--card components__blog_block--fallback' class='components__blog_block components__blog_block--card components__blog_block--fallback'
inert inert
> >
<h3>Pas de news disponible</h3> <h3>Pas de news disponible</h3>
</div> </div>
) )
} }

View file

@ -7,97 +7,96 @@ import { useEffect, useRef } from 'preact/hooks'
export type Builder<T> = (props: T) => JSX.Element export type Builder<T> = (props: T) => JSX.Element
export type CardListProps<ApiResponse, RefType = null> = { export type CardListProps<ApiResponse, RefType = null> = {
apiRoute: string apiRoute: string
builder: Builder<ApiResponse> builder: Builder<ApiResponse>
limit?: number limit?: number
} | { } | {
apiRoute: string apiRoute: string
builder: Builder<ApiResponse> builder: Builder<ApiResponse>
limit?: number limit?: number
useObserver?: boolean useObserver?: boolean
placeholder: ({ ref }: { ref: Ref<RefType> | undefined }) => JSX.Element placeholder: ({ ref }: { ref: Ref<RefType> | undefined }) => JSX.Element
fallback: Fallback fallback: Fallback
} }
export default function CardList<ApiResponse, RefType = null>( export default function CardList<ApiResponse, RefType = null>(
{ limit, builder, apiRoute, ...props }: CardListProps<ApiResponse, RefType>, { limit, builder, apiRoute, ...props }: CardListProps<ApiResponse, RefType>,
) { ) {
const list: Signal<JSX.Element[]> = useSignal<JSX.Element[]>([]) const list: Signal<JSX.Element[]> = useSignal<JSX.Element[]>([])
const ac = new AbortController() const ac = new AbortController()
const ref = useRef(null) const ref = useRef(null)
const useObserver = 'useObserver' in props ? props.useObserver : false const useObserver = 'useObserver' in props ? props.useObserver : false
const placeholder = 'placeholder' in props ? props.placeholder : false const placeholder = 'placeholder' in props ? props.placeholder : false
const fallback = 'fallback' in props ? props.fallback : false const fallback = 'fallback' in props ? props.fallback : false
useEffect(() => { useEffect(() => {
if (ref.current && useObserver) { if (ref.current && useObserver) {
const observer = new IntersectionObserver(([entry]) => { const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
fillList(list, builder, apiRoute, { limit, ac }) fillList(list, builder, apiRoute, { limit, ac })
observer.disconnect() observer.disconnect()
} }
}, { }, {
rootMargin: '300px', rootMargin: '300px',
}) })
observer.observe(ref.current) observer.observe(ref.current)
} else { } else {
fillList(list, builder, apiRoute, { limit, ac }) fillList(list, builder, apiRoute, { limit, ac })
} }
}) })
if (limit && placeholder && fallback) { if (limit && placeholder && fallback) {
const placeholders = Array const placeholders = Array
.from({ length: limit }) .from({ length: limit })
.map((_, index) => ( .map((_, index) => (
<Suspense <Suspense
loader={placeholder({ ref: index === 0 ? ref : undefined })} loader={placeholder({ ref: index === 0 ? ref : undefined })}
fallback={fallback} fallback={fallback}
signal={ac.signal} signal={ac.signal}
> value={updateFromList(list, index)}
{updateFromList(list, index)} />
</Suspense> ))
)) return <>{placeholders}</>
return <>{placeholders}</> }
}
return <>{list}</> return <>{list}</>
} }
function fillList<ApiResponse>( function fillList<ApiResponse>(
list: Signal<JSX.Element[]>, list: Signal<JSX.Element[]>,
builder: Builder<ApiResponse>, builder: Builder<ApiResponse>,
apiRoute: string, apiRoute: string,
{ limit, ac }: { limit?: number; ac?: AbortController }, { limit, ac }: { limit?: number; ac?: AbortController },
) { ) {
;(async () => { ;(async () => {
const propsList = requestApiStream<void, ApiResponse>( const propsList = requestApiStream<void, ApiResponse>(
apiRoute, apiRoute,
'GET', 'GET',
) )
for await (const props of propsList) { for await (const props of propsList) {
list.value = [ list.value = [
...list.value, ...list.value,
builder(props), builder(props),
] ]
if (limit && list.value.length >= limit) break if (limit && list.value.length >= limit) break
} }
ac?.abort() ac?.abort()
})() })()
} }
function updateFromList( function updateFromList(
list: Signal<JSX.Element[]>, list: Signal<JSX.Element[]>,
index: number, index: number,
): Promise<JSX.Element> { ): Promise<JSX.Element> {
const { promise, resolve } = Promise.withResolvers<JSX.Element>() const { promise, resolve } = Promise.withResolvers<JSX.Element>()
list.subscribe((value: JSX.Element[]) => { list.subscribe((value: JSX.Element[]) => {
const selected = value.at(index) const selected = value.at(index)
if (selected) { if (selected) {
resolve(selected) resolve(selected)
} }
}) })
return promise return promise
} }

View file

@ -3,58 +3,58 @@ import CardList from ':islands/CardList.tsx'
import type { Ref } from 'preact' import type { Ref } from 'preact'
export default function MemberCardList( export default function MemberCardList(
{ limit, filters, usePlaceholder, useObserver }: { { limit, filters, usePlaceholder, useObserver }: {
filters?: [string, string][] filters?: [string, string][]
limit?: number limit?: number
usePlaceholder?: boolean usePlaceholder?: boolean
useObserver?: boolean useObserver?: boolean
}, },
) { ) {
const query = new URL('members/fetchAll', 'https://null/') const query = new URL('members/fetchAll', 'https://null/')
filters?.forEach((filter) => query.searchParams.set(...filter)) filters?.forEach((filter) => query.searchParams.set(...filter))
const apiRoute = `${query.pathname}${query.search}` const apiRoute = `${query.pathname}${query.search}`
if (usePlaceholder) { if (usePlaceholder) {
return ( return (
<CardList <CardList
apiRoute={apiRoute} apiRoute={apiRoute}
builder={MemberCard} builder={MemberCard}
limit={limit} limit={limit}
placeholder={Placeholder} placeholder={Placeholder}
fallback={Fallback} fallback={Fallback}
useObserver={useObserver} useObserver={useObserver}
/> />
) )
} }
return ( return (
<CardList <CardList
apiRoute={apiRoute} apiRoute={apiRoute}
builder={MemberCard} builder={MemberCard}
limit={limit} limit={limit}
/> />
) )
} }
function Placeholder({ ref }: { ref?: Ref<HTMLDivElement> | undefined }) { function Placeholder({ ref }: { ref?: Ref<HTMLDivElement> | undefined }) {
return ( return (
<div <div
// class='components__blog_block components__blog_block--card components__blog_block--placeholder' // class='components__blog_block components__blog_block--card components__blog_block--placeholder'
ref={ref} ref={ref}
> >
<h3>Chargement ...</h3> <h3>Chargement ...</h3>
</div> </div>
) )
} }
function Fallback() { function Fallback() {
return ( return (
<div <div
// class='components__blog_block components__blog_block--card components__blog_block--fallback' // class='components__blog_block components__blog_block--card components__blog_block--fallback'
inert inert
> >
<h3>Pas d'utilisateur</h3> <h3>Pas d'utilisateur</h3>
</div> </div>
) )
} }

View file

@ -18,13 +18,14 @@ function RenderError(
export type Fallback = ({ error }: { error: Error }) => JSX.Element export type Fallback = ({ error }: { error: Error }) => JSX.Element
export type SuspenseProps = {
loader: JSX.Element
fallback?: Fallback
signal?: AbortSignal
} & ({ children: Promise<JSX.Element> } | { value: Promise<JSX.Element> })
export default function Suspense( export default function Suspense(
{ loader, fallback, signal, children }: { { loader, fallback, signal, ...props }: SuspenseProps,
loader: JSX.Element
children: Promise<JSX.Element>
fallback?: Fallback
signal?: AbortSignal
},
) { ) {
const displayed = useSignal(loader) const displayed = useSignal(loader)
let loaded = false let loaded = false
@ -38,7 +39,9 @@ export default function Suspense(
} }
}) })
children //Prevent transpilation error due to children expected to not be a promise
const inner = 'value' in props ? props.value : props.children
inner
.then((element) => { .then((element) => {
if (signal?.aborted) return if (signal?.aborted) return
displayed.value = element displayed.value = element

View file

@ -5,24 +5,24 @@ import { SessionHandlers } from ':src/session/mod.ts'
import { respondApi, respondApiStream } from ':src/utils.ts' import { respondApi, respondApiStream } from ':src/utils.ts'
export const handler: SessionHandlers = { export const handler: SessionHandlers = {
GET(_req, ctx) { GET(_req, ctx) {
try { try {
const memberList = dbToMemberCardProps(db) const memberList = dbToMemberCardProps(db)
const params = ctx.url.searchParams const params = ctx.url.searchParams
const groupParam = params.get('group') const groupParam = params.get('group')
if (groupParam) { if (groupParam) {
const list = memberList.filter( const list = memberList.filter(
(member) => member.groups.includes(groupParam), (member) => member.groups.includes(groupParam),
) as AsyncIterableIterator<MemberCardProps> ) as AsyncIterableIterator<MemberCardProps>
return respondApiStream(list) return respondApiStream(list)
} }
return respondApiStream(memberList) return respondApiStream(memberList)
} catch (error) { } catch (error) {
return respondApi('error', error) return respondApi('error', error)
} }
}, },
} }

View file

@ -3,12 +3,12 @@ import { SessionHandlers } from ':src/session/mod.ts'
import { respondApi, respondApiStream } from ':src/utils.ts' import { respondApi, respondApiStream } from ':src/utils.ts'
export const handler: SessionHandlers = { export const handler: SessionHandlers = {
GET() { GET() {
try { try {
const newsList = fetchNewsList('cohabit') const newsList = fetchNewsList('cohabit')
return respondApiStream(newsList) return respondApiStream(newsList)
} catch (error) { } catch (error) {
return respondApi('error', error) return respondApi('error', error)
} }
}, },
} }

View file

@ -2,7 +2,12 @@ import { db } from ':src/db/mod.ts'
import type { SessionHandlers } from ':src/session/mod.ts' import type { SessionHandlers } from ':src/session/mod.ts'
import { respondApi } from ':src/utils.ts' import { respondApi } from ':src/utils.ts'
import { getRelyingParty } from ':src/webauthn/mod.ts' import { getRelyingParty } from ':src/webauthn/mod.ts'
import { Credential, Passkey, Ref, User } from '@cohabit/resources-manager/models' import {
Credential,
Passkey,
Ref,
User,
} from '@cohabit/resources-manager/models'
import { import {
generateAuthenticationOptions, generateAuthenticationOptions,
verifyAuthenticationResponse, verifyAuthenticationResponse,

View file

@ -12,7 +12,12 @@ import type {
//TODO improve workspace imports //TODO improve workspace imports
import { db } from ':src/db/mod.ts' import { db } from ':src/db/mod.ts'
import { getRelyingParty } from ':src/webauthn/mod.ts' import { getRelyingParty } from ':src/webauthn/mod.ts'
import { Credential, Passkey, Ref, User } from '@cohabit/resources-manager/models' import {
Credential,
Passkey,
Ref,
User,
} from '@cohabit/resources-manager/models'
import { encodeBase64 } from '@std/encoding' import { encodeBase64 } from '@std/encoding'
type Params = { step: 'start' | 'finish' } type Params = { step: 'start' | 'finish' }

View file

@ -29,9 +29,8 @@ export default function Home() {
<section> <section>
<h2>Nos machines</h2> <h2>Nos machines</h2>
<p> <p>
Vous avez besoin d'aide pour concrétiser votre projet ? Le Vous avez besoin d'aide pour concrétiser votre projet ? Le Fablab vous
Fablab vous accompagnes dans vos projets, grâce à son parc accompagnes dans vos projets, grâce à son parc de machine...
de machine...
</p> </p>
<AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}> <AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}>
<> <>
@ -63,29 +62,26 @@ export default function Home() {
<section> <section>
<h2>Présentation</h2> <h2>Présentation</h2>
<p> <p>
Coh@bit est un fablab de l'université de Bordeaux ouvert à Coh@bit est un fablab de l'université de Bordeaux ouvert à tous les
tous les publics depuis 2016. Du collégien à publics depuis 2016. Du collégien à l'enseignant-chercheur, l'équipe
l'enseignant-chercheur, l'équipe du fablab accompagne les du fablab accompagne les adhérents dans la réalisation de leurs
adhérents dans la réalisation de leurs projets de projets de fabrication autour du numérique.
fabrication autour du numérique.
</p> </p>
<p> <p>
Venez découvrir un tout nouvelle univers vous pouvez Venez découvrir un tout nouvelle univers vous pouvez concrétiser
concrétiser vos projet, découvrir des personnes avec les vos projet, découvrir des personnes avec les même affinités que vous,
même affinités que vous, cultiver votre savoir et savoir cultiver votre savoir et savoir faire, dans l'entraide et le partage.
faire, dans l'entraide et le partage.
</p> </p>
<p> <p>
Créer par Frédéric Bos (Directeur de l'IUT de Bordeaux) en Créer par Frédéric Bos (Directeur de l'IUT de Bordeaux) en 2014,
2014, Coh@bit (Creative Open House at Bordeaux Institut of Coh@bit (Creative Open House at Bordeaux Institut of Technology) est
Technology) est une association réunissant deux entités : le une association réunissant deux entités : le Fablab et le Technoshop.
Fablab et le Technoshop.
</p> </p>
<p> <p>
Ouvert à tous les publics depuis 2016, allant de Ouvert à tous les publics depuis 2016, allant de
l'enseignant-chercheur au collégien, l'équipe du fablab l'enseignant-chercheur au collégien, l'équipe du fablab accompagne les
accompagne les adhérents dans la réalisation de leurs adhérents dans la réalisation de leurs projets de fabrication
projets de fabrication numérique. numérique.
</p> </p>
<CohabitInfoTable /> <CohabitInfoTable />
</section> </section>

View file

@ -4,101 +4,99 @@ import { base64ToString } from ':src/utils.ts'
import { extract } from '@std/front-matter/yaml' import { extract } from '@std/front-matter/yaml'
export async function fetchNews( export async function fetchNews(
publisher: string, publisher: string,
name: string, name: string,
): Promise<BlogProps> { ): Promise<BlogProps> {
const apiUrl = 'https://git.cohabit.fr/api/v1/' const apiUrl = 'https://git.cohabit.fr/api/v1/'
const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl) const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl)
const endpoint = new URL('contents/', baseEndpoint) const endpoint = new URL('contents/', baseEndpoint)
// Get readme content api url // Get readme content api url
const readmePath = encodeURIComponent(`${name}/README.md`) const readmePath = encodeURIComponent(`${name}/README.md`)
const contentUrl = new URL(readmePath, endpoint) const contentUrl = new URL(readmePath, endpoint)
// Fetch readme content, commit hash and raw url for relative links // Fetch readme content, commit hash and raw url for relative links
const file = await getCommitAndContent(contentUrl) const file = await getCommitAndContent(contentUrl)
// Get commit infos (author + date) and get readme content from base64 source // Get commit infos (author + date) and get readme content from base64 source
const { raw, url, lastUpdate, author } = await getAuthorAndParseContent( const { raw, url, lastUpdate, author } = await getAuthorAndParseContent(
file, file,
baseEndpoint, baseEndpoint,
) )
// Extract frontmatter // Extract frontmatter
const { attrs, body } = extract<NewsFrontMatter>(raw) const { attrs, body } = extract<NewsFrontMatter>(raw)
// Transform API responses into BlogProps for BlogCard and BlogPost components // Transform API responses into BlogProps for BlogCard and BlogPost components
return { return {
author, author,
publisher, publisher,
lastUpdate, lastUpdate,
options: attrs['x-cohabit'], options: attrs['x-cohabit'],
title: attrs.title, title: attrs.title,
hash: file.sha, hash: file.sha,
description: attrs.description, description: attrs.description,
body, body,
name, name,
url, url,
tags: attrs.tags, tags: attrs.tags,
} }
} }
export async function* fetchNewsList( export async function* fetchNewsList(
publisher: string, publisher: string,
): AsyncGenerator<BlogProps, void, void> { ): AsyncGenerator<BlogProps, void, void> {
const apiUrl = 'https://git.cohabit.fr/api/v1/' const apiUrl = 'https://git.cohabit.fr/api/v1/'
const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl) const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl)
const endpoint = new URL('contents/', baseEndpoint) const endpoint = new URL('contents/', baseEndpoint)
// Fetch repo content // Fetch repo content
const root = await fetch(endpoint).then((response) => response.json()) as { const root = await fetch(endpoint).then((response) => response.json()) as {
name: string name: string
type: string type: string
}[] }[]
// Fetch `README.md` in sub directories // Fetch `README.md` in sub directories
const blogPropsList = root const blogPropsList = root
// Remove file and dir starting with "." // Remove file and dir starting with "."
.filter(isNewsDirectory) .filter(isNewsDirectory)
// Fetch single news and return BlogProps // Fetch single news and return BlogProps
.map(({ name }) => fetchNews(publisher, name)) .map(({ name }) => fetchNews(publisher, name))
// Yield each news // Yield each news
for (const blogProps of blogPropsList) { for (const blogProps of blogPropsList) {
yield blogProps yield blogProps
} }
} }
async function getAuthorAndParseContent( async function getAuthorAndParseContent(
file: { download_url: string; content: string; last_commit_sha: string }, file: { download_url: string; content: string; last_commit_sha: string },
baseEndpoint: URL, baseEndpoint: URL,
) { ) {
const commitUrl = new URL( const commitUrl = new URL(
`git/commits/${file.last_commit_sha}?stat=false&verification=false&files=false`, `git/commits/${file.last_commit_sha}?stat=false&verification=false&files=false`,
baseEndpoint, baseEndpoint,
) )
const infos = await fetch(commitUrl).then((response) => const infos = await fetch(commitUrl).then((response) => response.json()) as {
response.json() created: string
) as { author: { login: string }
created: string }
author: { login: string }
}
return { return {
raw: base64ToString(file.content), raw: base64ToString(file.content),
url: file.download_url, url: file.download_url,
lastUpdate: new Date(infos.created), lastUpdate: new Date(infos.created),
author: infos.author.login, author: infos.author.login,
} }
} }
async function getCommitAndContent(contentUrl: URL) { async function getCommitAndContent(contentUrl: URL) {
return await fetch(contentUrl).then((response) => response.json()) as { return await fetch(contentUrl).then((response) => response.json()) as {
download_url: string download_url: string
content: string content: string
sha: string sha: string
last_commit_sha: string last_commit_sha: string
} }
} }
function isNewsDirectory(entry: { name: string; type: string }): boolean { function isNewsDirectory(entry: { name: string; type: string }): boolean {
return entry.type === 'dir' && entry.name.startsWith('.') === false return entry.type === 'dir' && entry.name.startsWith('.') === false
} }

View file

@ -1,19 +1,19 @@
import { FreshContext } from '$fresh/server.ts' import { FreshContext } from '$fresh/server.ts'
export function useCache( export function useCache(
_request: Request, _request: Request,
response: Response, response: Response,
ctx: FreshContext, ctx: FreshContext,
) { ) {
if (ctx.config.dev) return if (ctx.config.dev) return
if ( if (
ctx.url.pathname.match( ctx.url.pathname.match(
/(.+\.|_)((css)|(ttf)|(woff2)|(png)|(svg)|(jpe?g)|(avif))/, /(.+\.|_)((css)|(ttf)|(woff2)|(png)|(svg)|(jpe?g)|(avif))/,
) )
) { ) {
response.headers.set( response.headers.set(
'Cache-Control', 'Cache-Control',
'public, max-age=31536000, immutable', 'public, max-age=31536000, immutable',
) )
} }
} }

View file

@ -2,31 +2,31 @@ import { FreshContext } from '$fresh/server.ts'
import { applyCspRulesWithNonce, CspRules } from ':src/csp/mod.ts' import { applyCspRulesWithNonce, CspRules } from ':src/csp/mod.ts'
export function useCsp( export function useCsp(
_request: Request, _request: Request,
response: Response, response: Response,
ctx: FreshContext, ctx: FreshContext,
) { ) {
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSP //See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSP
const trustedDomains = ["'self'", 'https://git.cohabit.fr'] const trustedDomains = ["'self'", 'https://git.cohabit.fr']
const cspRules: CspRules = { const cspRules: CspRules = {
defaultSrc: ["'none'"], defaultSrc: ["'none'"],
frameAncestors: ["'none'"], frameAncestors: ["'none'"],
upgradeInsecureRequests: true, upgradeInsecureRequests: true,
styleSrc: [...trustedDomains, "'unsafe-inline'"], //set nonce to inline script styleSrc: [...trustedDomains, "'unsafe-inline'"], //set nonce to inline script
manifestSrc: [`${ctx.url.origin.replace('http:', 'https:')}/manifest.json`], manifestSrc: [`${ctx.url.origin.replace('http:', 'https:')}/manifest.json`],
baseUri: ["'none'"], baseUri: ["'none'"],
imgSrc: [ imgSrc: [
...trustedDomains, ...trustedDomains,
'data:', 'data:',
'https:', 'https:',
], ],
fontSrc: [...trustedDomains, 'https://cdn.jsdelivr.net'], fontSrc: [...trustedDomains, 'https://cdn.jsdelivr.net'],
scriptSrc: ["'self'", "'strict-dynamic'"], scriptSrc: ["'self'", "'strict-dynamic'"],
connectSrc: ["'self'"], connectSrc: ["'self'"],
formAction: ["'none'"], formAction: ["'none'"],
} }
return applyCspRulesWithNonce(response, cspRules) return applyCspRulesWithNonce(response, cspRules)
} }

View file

@ -2,51 +2,51 @@ import type { ContentSecurityPolicyDirectives } from '$fresh/runtime.ts'
import { getFreshNonce, toSnakeCase } from ':src/utils.ts' import { getFreshNonce, toSnakeCase } from ':src/utils.ts'
export type CspRules = ContentSecurityPolicyDirectives & { export type CspRules = ContentSecurityPolicyDirectives & {
upgradeInsecureRequests: true upgradeInsecureRequests: true
} }
export function applyCspRules( export function applyCspRules(
{ headers }: { headers: Headers }, { headers }: { headers: Headers },
rules: CspRules, rules: CspRules,
): void { ): void {
const rulesString: string[] = [] const rulesString: string[] = []
for (const rule in rules) { for (const rule in rules) {
const value = rules[rule as unknown as keyof CspRules] const value = rules[rule as unknown as keyof CspRules]
const ruleName = toSnakeCase(rule) const ruleName = toSnakeCase(rule)
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
rulesString.push(`${ruleName}`) rulesString.push(`${ruleName}`)
continue continue
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
rulesString.push(`${ruleName} ${value.join(' ')}`) rulesString.push(`${ruleName} ${value.join(' ')}`)
continue continue
} }
if (typeof value === 'string') { if (typeof value === 'string') {
rulesString.push(`${ruleName} ${value}`) rulesString.push(`${ruleName} ${value}`)
continue continue
} }
throw TypeError(`unsupported csp rule "${rule}" with value (${value})`) throw TypeError(`unsupported csp rule "${rule}" with value (${value})`)
} }
headers.set('Content-Security-Policy', rulesString.join('; ')) headers.set('Content-Security-Policy', rulesString.join('; '))
} }
export async function applyCspRulesWithNonce( export async function applyCspRulesWithNonce(
response: Response, response: Response,
rules: CspRules, rules: CspRules,
): Promise<void> { ): Promise<void> {
// Get nonce from any html response // Get nonce from any html response
const nonce = await getFreshNonce(response) const nonce = await getFreshNonce(response)
// Add nonce to script src if defined // Add nonce to script src if defined
if (nonce) { if (nonce) {
rules.scriptSrc?.push(`'nonce-${nonce}'`) rules.scriptSrc?.push(`'nonce-${nonce}'`)
} }
return applyCspRules(response, rules) return applyCspRules(response, rules)
} }

View file

@ -5,8 +5,8 @@ import type { MailAddress } from '@cohabit/resources-manager/types'
// Import Datas // Import Datas
import { exists } from '$std/fs/exists.ts' import { exists } from '$std/fs/exists.ts'
import { ensureDir } from '$std/fs/mod.ts' import { ensureDir } from '$std/fs/mod.ts'
import groups from ":src/db/mock/groups.json" with { type: 'json' } import groups from ':src/db/mock/groups.json' with { type: 'json' }
import users from ":src/db/mock/users.json" with { type: 'json' } import users from ':src/db/mock/users.json' with { type: 'json' }
await ensureDir('./cache') await ensureDir('./cache')
const dbPath = './cache/db.sqlite' const dbPath = './cache/db.sqlite'

View file

@ -1,26 +1,26 @@
import { FreshContext } from '$fresh/server.ts' import { FreshContext } from '$fresh/server.ts'
export function useSecurityHeaders( export function useSecurityHeaders(
_request: Request, _request: Request,
response: Response, response: Response,
_ctx: FreshContext, _ctx: FreshContext,
) { ) {
// See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_strict_transport_security // See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_strict_transport_security
response.headers.set( response.headers.set(
'Strict-Transport-Security', 'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload', 'max-age=63072000; includeSubDomains; preload',
) )
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Referrer_policy //See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Referrer_policy
response.headers.set( response.headers.set(
'Referrer-Policy', 'Referrer-Policy',
'no-referrer, strict-origin-when-cross-origin', 'no-referrer, strict-origin-when-cross-origin',
) )
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/MIME_types //See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/MIME_types
response.headers.set('X-Content-Type-Options', 'nosniff') response.headers.set('X-Content-Type-Options', 'nosniff')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Clickjacking //See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Clickjacking
response.headers.set('X-Frame-Options', 'DENY') response.headers.set('X-Frame-Options', 'DENY')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CORP //See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CORP
response.headers.set('Cross-Origin-Resource-Policy', 'same-origin') response.headers.set('Cross-Origin-Resource-Policy', 'same-origin')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity //See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
//? SRI plugin for non local resources only ? //? SRI plugin for non local resources only ?
} }

View file

@ -1,12 +1,12 @@
import { FreshContext } from '$fresh/server.ts' import { FreshContext } from '$fresh/server.ts'
export function useServiceworker( export function useServiceworker(
_request: Request, _request: Request,
response: Response, response: Response,
ctx: FreshContext, ctx: FreshContext,
) { ) {
// Allow service worker to serve root scope // Allow service worker to serve root scope
if (ctx.url.pathname.endsWith('island-startserviceworker.js')) { if (ctx.url.pathname.endsWith('island-startserviceworker.js')) {
response.headers.set('Service-Worker-Allowed', '/') response.headers.set('Service-Worker-Allowed', '/')
} }
} }

View file

@ -3,51 +3,51 @@ import { SessionStore } from ':src/session/mod.ts'
import { getCookies, setCookie } from 'jsr:@std/http@^0.224.4/cookie' import { getCookies, setCookie } from 'jsr:@std/http@^0.224.4/cookie'
export function useSession( export function useSession(
request: Request, request: Request,
response: Response, response: Response,
ctx: FreshContext, ctx: FreshContext,
) { ) {
// Check if session already started // Check if session already started
if (SessionStore.getFromRequest(request) !== undefined) { if (SessionStore.getFromRequest(request) !== undefined) {
return return
} }
// Clear outdated cookies // Clear outdated cookies
for (const cookie in getCookies(request.headers)) { for (const cookie in getCookies(request.headers)) {
setCookie(response.headers, { setCookie(response.headers, {
name: cookie, name: cookie,
value: '', value: '',
path: '/', path: '/',
expires: 0, expires: 0,
}) })
} }
// Create new session // Create new session
const session = SessionStore.createSession() const session = SessionStore.createSession()
ctx.state = { ...ctx.state, session } ctx.state = { ...ctx.state, session }
// Set session cookie // Set session cookie
setCookie(response.headers, { setCookie(response.headers, {
name: '__Secure-SESSION', name: '__Secure-SESSION',
value: session.uuid, value: session.uuid,
httpOnly: true, httpOnly: true,
sameSite: 'Strict', sameSite: 'Strict',
secure: true, secure: true,
path: '/', path: '/',
expires: SessionStore.maxAge, expires: SessionStore.maxAge,
}) })
// Set csrf // Set csrf
const csrf = crypto.randomUUID() const csrf = crypto.randomUUID()
session.set('_csrf', csrf) session.set('_csrf', csrf)
setCookie(response.headers, { setCookie(response.headers, {
name: '__Host-CSRF', name: '__Host-CSRF',
value: csrf, value: csrf,
httpOnly: false, httpOnly: false,
sameSite: 'Strict', sameSite: 'Strict',
secure: true, secure: true,
path: '/', path: '/',
expires: SessionStore.maxAge, expires: SessionStore.maxAge,
}) })
} }