fix(island): 🐛 transpilation cause Suspense
to broke promise as children
This commit is contained in:
parent
f24143964f
commit
c6eb438314
|
@ -13,8 +13,7 @@ export function AutoGrid(
|
|||
<div
|
||||
class='components__auto_grid'
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
`repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
|
||||
gridTemplateColumns: `repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -69,9 +69,7 @@ export function BlogPost(
|
|||
<div class='components__blog_post__infos'>
|
||||
<span>{`Visibilité : ${options.visibility}`}</span>
|
||||
<span>
|
||||
{`Date de délivrance : ${
|
||||
new Date(options.dueDate).toLocaleString()
|
||||
}`}
|
||||
{`Date de délivrance : ${new Date(options.dueDate).toLocaleString()}`}
|
||||
</span>
|
||||
</div>
|
||||
<div class='components__blog_post__description'>
|
||||
|
@ -90,9 +88,7 @@ export function BlogPost(
|
|||
function NewsTags({ tags }: Pick<BlogProps, 'tags'>) {
|
||||
return (
|
||||
<div class='components__blog_block__tags'>
|
||||
{tags
|
||||
? tags.map((tag) => <span>{tag}</span>)
|
||||
: <span>Aucun tag</span>}
|
||||
{tags ? tags.map((tag) => <span>{tag}</span>) : <span>Aucun tag</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,26 +3,26 @@ import { Ensure } from ':types'
|
|||
import { JSX } from 'preact'
|
||||
|
||||
export type PictureProps =
|
||||
& { formats: string[] }
|
||||
& Ensure<JSX.HTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'loading'>
|
||||
& { formats: string[] }
|
||||
& Ensure<JSX.HTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'loading'>
|
||||
|
||||
export function Picture(
|
||||
{ formats, src, ...props }: PictureProps,
|
||||
{ formats, src, ...props }: PictureProps,
|
||||
) {
|
||||
const groups = unwrapSignalOrValue(src)?.match(/(?<path>.*)(?<ext>\.\w+)/)
|
||||
?.groups
|
||||
if (groups === undefined) {
|
||||
throw new SyntaxError(`unable to parse path of "${src.valueOf()}"`)
|
||||
}
|
||||
const groups = unwrapSignalOrValue(src)?.match(/(?<path>.*)(?<ext>\.\w+)/)
|
||||
?.groups
|
||||
if (groups === undefined) {
|
||||
throw new SyntaxError(`unable to parse path of "${src.valueOf()}"`)
|
||||
}
|
||||
|
||||
const { path } = groups
|
||||
const { path } = groups
|
||||
|
||||
return (
|
||||
<picture>
|
||||
{formats.map((format) => (
|
||||
<source type={`image/${format}`} srcset={`${path}.${format}`} />
|
||||
))}
|
||||
<img src={src} {...props} />
|
||||
</picture>
|
||||
)
|
||||
return (
|
||||
<picture>
|
||||
{formats.map((format) => (
|
||||
<source type={`image/${format}`} srcset={`${path}.${format}`} />
|
||||
))}
|
||||
<img src={src} {...props} />
|
||||
</picture>
|
||||
)
|
||||
}
|
||||
|
|
10
dev.ts
10
dev.ts
|
@ -6,9 +6,9 @@ import config from './fresh.config.ts'
|
|||
import '$std/dotenv/load.ts'
|
||||
|
||||
await dev(import.meta.url, './main.ts', {
|
||||
...config,
|
||||
server: {
|
||||
cert: await Deno.readTextFile('./cert/localhost.pem'),
|
||||
key: await Deno.readTextFile('./cert/localhost-key.pem'),
|
||||
},
|
||||
...config,
|
||||
server: {
|
||||
cert: await Deno.readTextFile('./cert/localhost.pem'),
|
||||
key: await Deno.readTextFile('./cert/localhost-key.pem'),
|
||||
},
|
||||
})
|
||||
|
|
|
@ -3,56 +3,56 @@ import CardList from ':islands/CardList.tsx'
|
|||
import type { Ref } from 'preact'
|
||||
|
||||
export default function BlogCardList(
|
||||
{ limit, usePlaceholder, useObserver }: {
|
||||
limit?: number
|
||||
usePlaceholder?: boolean
|
||||
useObserver?: boolean
|
||||
},
|
||||
{ limit, usePlaceholder, useObserver }: {
|
||||
limit?: number
|
||||
usePlaceholder?: boolean
|
||||
useObserver?: boolean
|
||||
},
|
||||
) {
|
||||
if (usePlaceholder) {
|
||||
return (
|
||||
<CardList
|
||||
apiRoute='news/fetchAll'
|
||||
builder={builder}
|
||||
limit={limit}
|
||||
placeholder={Placeholder}
|
||||
fallback={Fallback}
|
||||
useObserver={useObserver}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (usePlaceholder) {
|
||||
return (
|
||||
<CardList
|
||||
apiRoute='news/fetchAll'
|
||||
builder={builder}
|
||||
limit={limit}
|
||||
placeholder={Placeholder}
|
||||
fallback={Fallback}
|
||||
useObserver={useObserver}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CardList
|
||||
apiRoute='news/fetchAll'
|
||||
builder={builder}
|
||||
limit={limit}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<CardList
|
||||
apiRoute='news/fetchAll'
|
||||
builder={builder}
|
||||
limit={limit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<div
|
||||
class='components__blog_block components__blog_block--card components__blog_block--placeholder'
|
||||
ref={ref}
|
||||
>
|
||||
<h3>Chargement ...</h3>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
class='components__blog_block components__blog_block--card components__blog_block--placeholder'
|
||||
ref={ref}
|
||||
>
|
||||
<h3>Chargement ...</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fallback() {
|
||||
return (
|
||||
<div
|
||||
class='components__blog_block components__blog_block--card components__blog_block--fallback'
|
||||
inert
|
||||
>
|
||||
<h3>Pas de news disponible</h3>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
class='components__blog_block components__blog_block--card components__blog_block--fallback'
|
||||
inert
|
||||
>
|
||||
<h3>Pas de news disponible</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,97 +7,96 @@ import { useEffect, useRef } from 'preact/hooks'
|
|||
export type Builder<T> = (props: T) => JSX.Element
|
||||
|
||||
export type CardListProps<ApiResponse, RefType = null> = {
|
||||
apiRoute: string
|
||||
builder: Builder<ApiResponse>
|
||||
limit?: number
|
||||
apiRoute: string
|
||||
builder: Builder<ApiResponse>
|
||||
limit?: number
|
||||
} | {
|
||||
apiRoute: string
|
||||
builder: Builder<ApiResponse>
|
||||
limit?: number
|
||||
useObserver?: boolean
|
||||
placeholder: ({ ref }: { ref: Ref<RefType> | undefined }) => JSX.Element
|
||||
fallback: Fallback
|
||||
apiRoute: string
|
||||
builder: Builder<ApiResponse>
|
||||
limit?: number
|
||||
useObserver?: boolean
|
||||
placeholder: ({ ref }: { ref: Ref<RefType> | undefined }) => JSX.Element
|
||||
fallback: Fallback
|
||||
}
|
||||
|
||||
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 ac = new AbortController()
|
||||
const ref = useRef(null)
|
||||
const list: Signal<JSX.Element[]> = useSignal<JSX.Element[]>([])
|
||||
const ac = new AbortController()
|
||||
const ref = useRef(null)
|
||||
|
||||
const useObserver = 'useObserver' in props ? props.useObserver : false
|
||||
const placeholder = 'placeholder' in props ? props.placeholder : false
|
||||
const fallback = 'fallback' in props ? props.fallback : false
|
||||
const useObserver = 'useObserver' in props ? props.useObserver : false
|
||||
const placeholder = 'placeholder' in props ? props.placeholder : false
|
||||
const fallback = 'fallback' in props ? props.fallback : false
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && useObserver) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
fillList(list, builder, apiRoute, { limit, ac })
|
||||
observer.disconnect()
|
||||
}
|
||||
}, {
|
||||
rootMargin: '300px',
|
||||
})
|
||||
observer.observe(ref.current)
|
||||
} else {
|
||||
fillList(list, builder, apiRoute, { limit, ac })
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
if (ref.current && useObserver) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
fillList(list, builder, apiRoute, { limit, ac })
|
||||
observer.disconnect()
|
||||
}
|
||||
}, {
|
||||
rootMargin: '300px',
|
||||
})
|
||||
observer.observe(ref.current)
|
||||
} else {
|
||||
fillList(list, builder, apiRoute, { limit, ac })
|
||||
}
|
||||
})
|
||||
|
||||
if (limit && placeholder && fallback) {
|
||||
const placeholders = Array
|
||||
.from({ length: limit })
|
||||
.map((_, index) => (
|
||||
<Suspense
|
||||
loader={placeholder({ ref: index === 0 ? ref : undefined })}
|
||||
fallback={fallback}
|
||||
signal={ac.signal}
|
||||
>
|
||||
{updateFromList(list, index)}
|
||||
</Suspense>
|
||||
))
|
||||
return <>{placeholders}</>
|
||||
}
|
||||
if (limit && placeholder && fallback) {
|
||||
const placeholders = Array
|
||||
.from({ length: limit })
|
||||
.map((_, index) => (
|
||||
<Suspense
|
||||
loader={placeholder({ ref: index === 0 ? ref : undefined })}
|
||||
fallback={fallback}
|
||||
signal={ac.signal}
|
||||
value={updateFromList(list, index)}
|
||||
/>
|
||||
))
|
||||
return <>{placeholders}</>
|
||||
}
|
||||
|
||||
return <>{list}</>
|
||||
return <>{list}</>
|
||||
}
|
||||
|
||||
function fillList<ApiResponse>(
|
||||
list: Signal<JSX.Element[]>,
|
||||
builder: Builder<ApiResponse>,
|
||||
apiRoute: string,
|
||||
{ limit, ac }: { limit?: number; ac?: AbortController },
|
||||
list: Signal<JSX.Element[]>,
|
||||
builder: Builder<ApiResponse>,
|
||||
apiRoute: string,
|
||||
{ limit, ac }: { limit?: number; ac?: AbortController },
|
||||
) {
|
||||
;(async () => {
|
||||
const propsList = requestApiStream<void, ApiResponse>(
|
||||
apiRoute,
|
||||
'GET',
|
||||
)
|
||||
;(async () => {
|
||||
const propsList = requestApiStream<void, ApiResponse>(
|
||||
apiRoute,
|
||||
'GET',
|
||||
)
|
||||
|
||||
for await (const props of propsList) {
|
||||
list.value = [
|
||||
...list.value,
|
||||
builder(props),
|
||||
]
|
||||
if (limit && list.value.length >= limit) break
|
||||
}
|
||||
ac?.abort()
|
||||
})()
|
||||
for await (const props of propsList) {
|
||||
list.value = [
|
||||
...list.value,
|
||||
builder(props),
|
||||
]
|
||||
if (limit && list.value.length >= limit) break
|
||||
}
|
||||
ac?.abort()
|
||||
})()
|
||||
}
|
||||
|
||||
function updateFromList(
|
||||
list: Signal<JSX.Element[]>,
|
||||
index: number,
|
||||
list: Signal<JSX.Element[]>,
|
||||
index: number,
|
||||
): Promise<JSX.Element> {
|
||||
const { promise, resolve } = Promise.withResolvers<JSX.Element>()
|
||||
list.subscribe((value: JSX.Element[]) => {
|
||||
const selected = value.at(index)
|
||||
if (selected) {
|
||||
resolve(selected)
|
||||
}
|
||||
})
|
||||
const { promise, resolve } = Promise.withResolvers<JSX.Element>()
|
||||
list.subscribe((value: JSX.Element[]) => {
|
||||
const selected = value.at(index)
|
||||
if (selected) {
|
||||
resolve(selected)
|
||||
}
|
||||
})
|
||||
|
||||
return promise
|
||||
return promise
|
||||
}
|
||||
|
|
|
@ -3,58 +3,58 @@ import CardList from ':islands/CardList.tsx'
|
|||
import type { Ref } from 'preact'
|
||||
|
||||
export default function MemberCardList(
|
||||
{ limit, filters, usePlaceholder, useObserver }: {
|
||||
filters?: [string, string][]
|
||||
limit?: number
|
||||
usePlaceholder?: boolean
|
||||
useObserver?: boolean
|
||||
},
|
||||
{ limit, filters, usePlaceholder, useObserver }: {
|
||||
filters?: [string, string][]
|
||||
limit?: number
|
||||
usePlaceholder?: boolean
|
||||
useObserver?: boolean
|
||||
},
|
||||
) {
|
||||
const query = new URL('members/fetchAll', 'https://null/')
|
||||
filters?.forEach((filter) => query.searchParams.set(...filter))
|
||||
const query = new URL('members/fetchAll', 'https://null/')
|
||||
filters?.forEach((filter) => query.searchParams.set(...filter))
|
||||
|
||||
const apiRoute = `${query.pathname}${query.search}`
|
||||
const apiRoute = `${query.pathname}${query.search}`
|
||||
|
||||
if (usePlaceholder) {
|
||||
return (
|
||||
<CardList
|
||||
apiRoute={apiRoute}
|
||||
builder={MemberCard}
|
||||
limit={limit}
|
||||
placeholder={Placeholder}
|
||||
fallback={Fallback}
|
||||
useObserver={useObserver}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (usePlaceholder) {
|
||||
return (
|
||||
<CardList
|
||||
apiRoute={apiRoute}
|
||||
builder={MemberCard}
|
||||
limit={limit}
|
||||
placeholder={Placeholder}
|
||||
fallback={Fallback}
|
||||
useObserver={useObserver}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CardList
|
||||
apiRoute={apiRoute}
|
||||
builder={MemberCard}
|
||||
limit={limit}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<CardList
|
||||
apiRoute={apiRoute}
|
||||
builder={MemberCard}
|
||||
limit={limit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Placeholder({ ref }: { ref?: Ref<HTMLDivElement> | undefined }) {
|
||||
return (
|
||||
<div
|
||||
// class='components__blog_block components__blog_block--card components__blog_block--placeholder'
|
||||
ref={ref}
|
||||
>
|
||||
<h3>Chargement ...</h3>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
// class='components__blog_block components__blog_block--card components__blog_block--placeholder'
|
||||
ref={ref}
|
||||
>
|
||||
<h3>Chargement ...</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fallback() {
|
||||
return (
|
||||
<div
|
||||
// class='components__blog_block components__blog_block--card components__blog_block--fallback'
|
||||
inert
|
||||
>
|
||||
<h3>Pas d'utilisateur</h3>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
// class='components__blog_block components__blog_block--card components__blog_block--fallback'
|
||||
inert
|
||||
>
|
||||
<h3>Pas d'utilisateur</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -18,13 +18,14 @@ function RenderError(
|
|||
|
||||
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(
|
||||
{ loader, fallback, signal, children }: {
|
||||
loader: JSX.Element
|
||||
children: Promise<JSX.Element>
|
||||
fallback?: Fallback
|
||||
signal?: AbortSignal
|
||||
},
|
||||
{ loader, fallback, signal, ...props }: SuspenseProps,
|
||||
) {
|
||||
const displayed = useSignal(loader)
|
||||
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) => {
|
||||
if (signal?.aborted) return
|
||||
displayed.value = element
|
||||
|
|
|
@ -5,24 +5,24 @@ import { SessionHandlers } from ':src/session/mod.ts'
|
|||
import { respondApi, respondApiStream } from ':src/utils.ts'
|
||||
|
||||
export const handler: SessionHandlers = {
|
||||
GET(_req, ctx) {
|
||||
try {
|
||||
const memberList = dbToMemberCardProps(db)
|
||||
GET(_req, ctx) {
|
||||
try {
|
||||
const memberList = dbToMemberCardProps(db)
|
||||
|
||||
const params = ctx.url.searchParams
|
||||
const params = ctx.url.searchParams
|
||||
|
||||
const groupParam = params.get('group')
|
||||
if (groupParam) {
|
||||
const list = memberList.filter(
|
||||
(member) => member.groups.includes(groupParam),
|
||||
) as AsyncIterableIterator<MemberCardProps>
|
||||
const groupParam = params.get('group')
|
||||
if (groupParam) {
|
||||
const list = memberList.filter(
|
||||
(member) => member.groups.includes(groupParam),
|
||||
) as AsyncIterableIterator<MemberCardProps>
|
||||
|
||||
return respondApiStream(list)
|
||||
}
|
||||
return respondApiStream(list)
|
||||
}
|
||||
|
||||
return respondApiStream(memberList)
|
||||
} catch (error) {
|
||||
return respondApi('error', error)
|
||||
}
|
||||
},
|
||||
return respondApiStream(memberList)
|
||||
} catch (error) {
|
||||
return respondApi('error', error)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -3,12 +3,12 @@ import { SessionHandlers } from ':src/session/mod.ts'
|
|||
import { respondApi, respondApiStream } from ':src/utils.ts'
|
||||
|
||||
export const handler: SessionHandlers = {
|
||||
GET() {
|
||||
try {
|
||||
const newsList = fetchNewsList('cohabit')
|
||||
return respondApiStream(newsList)
|
||||
} catch (error) {
|
||||
return respondApi('error', error)
|
||||
}
|
||||
},
|
||||
GET() {
|
||||
try {
|
||||
const newsList = fetchNewsList('cohabit')
|
||||
return respondApiStream(newsList)
|
||||
} catch (error) {
|
||||
return respondApi('error', error)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -2,7 +2,12 @@ import { db } from ':src/db/mod.ts'
|
|||
import type { SessionHandlers } from ':src/session/mod.ts'
|
||||
import { respondApi } from ':src/utils.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 {
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
|
|
|
@ -12,7 +12,12 @@ import type {
|
|||
//TODO improve workspace imports
|
||||
import { db } from ':src/db/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'
|
||||
|
||||
type Params = { step: 'start' | 'finish' }
|
||||
|
|
|
@ -29,9 +29,8 @@ export default function Home() {
|
|||
<section>
|
||||
<h2>Nos machines</h2>
|
||||
<p>
|
||||
Vous avez besoin d'aide pour concrétiser votre projet ? Le
|
||||
Fablab vous accompagnes dans vos projets, grâce à son parc
|
||||
de machine...
|
||||
Vous avez besoin d'aide pour concrétiser votre projet ? Le Fablab vous
|
||||
accompagnes dans vos projets, grâce à son parc de machine...
|
||||
</p>
|
||||
<AutoGrid columnWidth='15rem' style={{ alignItems: 'center' }}>
|
||||
<>
|
||||
|
@ -63,29 +62,26 @@ export default function Home() {
|
|||
<section>
|
||||
<h2>Présentation</h2>
|
||||
<p>
|
||||
Coh@bit est un fablab de l'université de Bordeaux ouvert à
|
||||
tous les publics depuis 2016. Du collégien à
|
||||
l'enseignant-chercheur, l'équipe du fablab accompagne les
|
||||
adhérents dans la réalisation de leurs projets de
|
||||
fabrication autour du numérique.
|
||||
Coh@bit est un fablab de l'université de Bordeaux ouvert à tous les
|
||||
publics depuis 2016. Du collégien à l'enseignant-chercheur, l'équipe
|
||||
du fablab accompagne les adhérents dans la réalisation de leurs
|
||||
projets de fabrication autour du numérique.
|
||||
</p>
|
||||
<p>
|
||||
Venez découvrir un tout nouvelle univers où vous pouvez
|
||||
concrétiser vos projet, découvrir des personnes avec les
|
||||
même affinités que vous, cultiver votre savoir et savoir
|
||||
faire, dans l'entraide et le partage.
|
||||
Venez découvrir un tout nouvelle univers où vous pouvez concrétiser
|
||||
vos projet, découvrir des personnes avec les même affinités que vous,
|
||||
cultiver votre savoir et savoir faire, dans l'entraide et le partage.
|
||||
</p>
|
||||
<p>
|
||||
Créer par Frédéric Bos (Directeur de l'IUT de Bordeaux) en
|
||||
2014, Coh@bit (Creative Open House at Bordeaux Institut of
|
||||
Technology) est une association réunissant deux entités : le
|
||||
Fablab et le Technoshop.
|
||||
Créer par Frédéric Bos (Directeur de l'IUT de Bordeaux) en 2014,
|
||||
Coh@bit (Creative Open House at Bordeaux Institut of Technology) est
|
||||
une association réunissant deux entités : le Fablab et le Technoshop.
|
||||
</p>
|
||||
<p>
|
||||
Ouvert à tous les publics depuis 2016, allant de
|
||||
l'enseignant-chercheur au collégien, l'équipe du fablab
|
||||
accompagne les adhérents dans la réalisation de leurs
|
||||
projets de fabrication numérique.
|
||||
l'enseignant-chercheur au collégien, l'équipe du fablab accompagne les
|
||||
adhérents dans la réalisation de leurs projets de fabrication
|
||||
numérique.
|
||||
</p>
|
||||
<CohabitInfoTable />
|
||||
</section>
|
||||
|
|
148
src/blog/mod.ts
148
src/blog/mod.ts
|
@ -4,101 +4,99 @@ import { base64ToString } from ':src/utils.ts'
|
|||
import { extract } from '@std/front-matter/yaml'
|
||||
|
||||
export async function fetchNews(
|
||||
publisher: string,
|
||||
name: string,
|
||||
publisher: string,
|
||||
name: string,
|
||||
): Promise<BlogProps> {
|
||||
const apiUrl = 'https://git.cohabit.fr/api/v1/'
|
||||
const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl)
|
||||
const endpoint = new URL('contents/', baseEndpoint)
|
||||
const apiUrl = 'https://git.cohabit.fr/api/v1/'
|
||||
const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl)
|
||||
const endpoint = new URL('contents/', baseEndpoint)
|
||||
|
||||
// Get readme content api url
|
||||
const readmePath = encodeURIComponent(`${name}/README.md`)
|
||||
const contentUrl = new URL(readmePath, endpoint)
|
||||
// Get readme content api url
|
||||
const readmePath = encodeURIComponent(`${name}/README.md`)
|
||||
const contentUrl = new URL(readmePath, endpoint)
|
||||
|
||||
// Fetch readme content, commit hash and raw url for relative links
|
||||
const file = await getCommitAndContent(contentUrl)
|
||||
// Get commit infos (author + date) and get readme content from base64 source
|
||||
const { raw, url, lastUpdate, author } = await getAuthorAndParseContent(
|
||||
file,
|
||||
baseEndpoint,
|
||||
)
|
||||
// Extract frontmatter
|
||||
const { attrs, body } = extract<NewsFrontMatter>(raw)
|
||||
// Fetch readme content, commit hash and raw url for relative links
|
||||
const file = await getCommitAndContent(contentUrl)
|
||||
// Get commit infos (author + date) and get readme content from base64 source
|
||||
const { raw, url, lastUpdate, author } = await getAuthorAndParseContent(
|
||||
file,
|
||||
baseEndpoint,
|
||||
)
|
||||
// Extract frontmatter
|
||||
const { attrs, body } = extract<NewsFrontMatter>(raw)
|
||||
|
||||
// Transform API responses into BlogProps for BlogCard and BlogPost components
|
||||
return {
|
||||
author,
|
||||
publisher,
|
||||
lastUpdate,
|
||||
options: attrs['x-cohabit'],
|
||||
title: attrs.title,
|
||||
hash: file.sha,
|
||||
description: attrs.description,
|
||||
body,
|
||||
name,
|
||||
url,
|
||||
tags: attrs.tags,
|
||||
}
|
||||
// Transform API responses into BlogProps for BlogCard and BlogPost components
|
||||
return {
|
||||
author,
|
||||
publisher,
|
||||
lastUpdate,
|
||||
options: attrs['x-cohabit'],
|
||||
title: attrs.title,
|
||||
hash: file.sha,
|
||||
description: attrs.description,
|
||||
body,
|
||||
name,
|
||||
url,
|
||||
tags: attrs.tags,
|
||||
}
|
||||
}
|
||||
|
||||
export async function* fetchNewsList(
|
||||
publisher: string,
|
||||
publisher: string,
|
||||
): AsyncGenerator<BlogProps, void, void> {
|
||||
const apiUrl = 'https://git.cohabit.fr/api/v1/'
|
||||
const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl)
|
||||
const endpoint = new URL('contents/', baseEndpoint)
|
||||
const apiUrl = 'https://git.cohabit.fr/api/v1/'
|
||||
const baseEndpoint = new URL(`repos/${publisher}/.news/`, apiUrl)
|
||||
const endpoint = new URL('contents/', baseEndpoint)
|
||||
|
||||
// Fetch repo content
|
||||
const root = await fetch(endpoint).then((response) => response.json()) as {
|
||||
name: string
|
||||
type: string
|
||||
}[]
|
||||
// Fetch repo content
|
||||
const root = await fetch(endpoint).then((response) => response.json()) as {
|
||||
name: string
|
||||
type: string
|
||||
}[]
|
||||
|
||||
// Fetch `README.md` in sub directories
|
||||
const blogPropsList = root
|
||||
// Remove file and dir starting with "."
|
||||
.filter(isNewsDirectory)
|
||||
// Fetch single news and return BlogProps
|
||||
.map(({ name }) => fetchNews(publisher, name))
|
||||
// Fetch `README.md` in sub directories
|
||||
const blogPropsList = root
|
||||
// Remove file and dir starting with "."
|
||||
.filter(isNewsDirectory)
|
||||
// Fetch single news and return BlogProps
|
||||
.map(({ name }) => fetchNews(publisher, name))
|
||||
|
||||
// Yield each news
|
||||
for (const blogProps of blogPropsList) {
|
||||
yield blogProps
|
||||
}
|
||||
// Yield each news
|
||||
for (const blogProps of blogPropsList) {
|
||||
yield blogProps
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuthorAndParseContent(
|
||||
file: { download_url: string; content: string; last_commit_sha: string },
|
||||
baseEndpoint: URL,
|
||||
file: { download_url: string; content: string; last_commit_sha: string },
|
||||
baseEndpoint: URL,
|
||||
) {
|
||||
const commitUrl = new URL(
|
||||
`git/commits/${file.last_commit_sha}?stat=false&verification=false&files=false`,
|
||||
baseEndpoint,
|
||||
)
|
||||
const infos = await fetch(commitUrl).then((response) =>
|
||||
response.json()
|
||||
) as {
|
||||
created: string
|
||||
author: { login: string }
|
||||
}
|
||||
const commitUrl = new URL(
|
||||
`git/commits/${file.last_commit_sha}?stat=false&verification=false&files=false`,
|
||||
baseEndpoint,
|
||||
)
|
||||
const infos = await fetch(commitUrl).then((response) => response.json()) as {
|
||||
created: string
|
||||
author: { login: string }
|
||||
}
|
||||
|
||||
return {
|
||||
raw: base64ToString(file.content),
|
||||
url: file.download_url,
|
||||
lastUpdate: new Date(infos.created),
|
||||
author: infos.author.login,
|
||||
}
|
||||
return {
|
||||
raw: base64ToString(file.content),
|
||||
url: file.download_url,
|
||||
lastUpdate: new Date(infos.created),
|
||||
author: infos.author.login,
|
||||
}
|
||||
}
|
||||
|
||||
async function getCommitAndContent(contentUrl: URL) {
|
||||
return await fetch(contentUrl).then((response) => response.json()) as {
|
||||
download_url: string
|
||||
content: string
|
||||
sha: string
|
||||
last_commit_sha: string
|
||||
}
|
||||
return await fetch(contentUrl).then((response) => response.json()) as {
|
||||
download_url: string
|
||||
content: string
|
||||
sha: string
|
||||
last_commit_sha: string
|
||||
}
|
||||
}
|
||||
|
||||
function isNewsDirectory(entry: { name: string; type: string }): boolean {
|
||||
return entry.type === 'dir' && entry.name.startsWith('.') === false
|
||||
return entry.type === 'dir' && entry.name.startsWith('.') === false
|
||||
}
|
||||
|
|
28
src/cache/middleware.ts
vendored
28
src/cache/middleware.ts
vendored
|
@ -1,19 +1,19 @@
|
|||
import { FreshContext } from '$fresh/server.ts'
|
||||
|
||||
export function useCache(
|
||||
_request: Request,
|
||||
response: Response,
|
||||
ctx: FreshContext,
|
||||
_request: Request,
|
||||
response: Response,
|
||||
ctx: FreshContext,
|
||||
) {
|
||||
if (ctx.config.dev) return
|
||||
if (
|
||||
ctx.url.pathname.match(
|
||||
/(.+\.|_)((css)|(ttf)|(woff2)|(png)|(svg)|(jpe?g)|(avif))/,
|
||||
)
|
||||
) {
|
||||
response.headers.set(
|
||||
'Cache-Control',
|
||||
'public, max-age=31536000, immutable',
|
||||
)
|
||||
}
|
||||
if (ctx.config.dev) return
|
||||
if (
|
||||
ctx.url.pathname.match(
|
||||
/(.+\.|_)((css)|(ttf)|(woff2)|(png)|(svg)|(jpe?g)|(avif))/,
|
||||
)
|
||||
) {
|
||||
response.headers.set(
|
||||
'Cache-Control',
|
||||
'public, max-age=31536000, immutable',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,31 +2,31 @@ import { FreshContext } from '$fresh/server.ts'
|
|||
import { applyCspRulesWithNonce, CspRules } from ':src/csp/mod.ts'
|
||||
|
||||
export function useCsp(
|
||||
_request: Request,
|
||||
response: Response,
|
||||
ctx: FreshContext,
|
||||
_request: Request,
|
||||
response: Response,
|
||||
ctx: FreshContext,
|
||||
) {
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSP
|
||||
|
||||
const trustedDomains = ["'self'", 'https://git.cohabit.fr']
|
||||
|
||||
const cspRules: CspRules = {
|
||||
defaultSrc: ["'none'"],
|
||||
frameAncestors: ["'none'"],
|
||||
upgradeInsecureRequests: true,
|
||||
styleSrc: [...trustedDomains, "'unsafe-inline'"], //set nonce to inline script
|
||||
manifestSrc: [`${ctx.url.origin.replace('http:', 'https:')}/manifest.json`],
|
||||
baseUri: ["'none'"],
|
||||
imgSrc: [
|
||||
...trustedDomains,
|
||||
'data:',
|
||||
'https:',
|
||||
],
|
||||
fontSrc: [...trustedDomains, 'https://cdn.jsdelivr.net'],
|
||||
scriptSrc: ["'self'", "'strict-dynamic'"],
|
||||
connectSrc: ["'self'"],
|
||||
formAction: ["'none'"],
|
||||
}
|
||||
|
||||
return applyCspRulesWithNonce(response, cspRules)
|
||||
const trustedDomains = ["'self'", 'https://git.cohabit.fr']
|
||||
|
||||
const cspRules: CspRules = {
|
||||
defaultSrc: ["'none'"],
|
||||
frameAncestors: ["'none'"],
|
||||
upgradeInsecureRequests: true,
|
||||
styleSrc: [...trustedDomains, "'unsafe-inline'"], //set nonce to inline script
|
||||
manifestSrc: [`${ctx.url.origin.replace('http:', 'https:')}/manifest.json`],
|
||||
baseUri: ["'none'"],
|
||||
imgSrc: [
|
||||
...trustedDomains,
|
||||
'data:',
|
||||
'https:',
|
||||
],
|
||||
fontSrc: [...trustedDomains, 'https://cdn.jsdelivr.net'],
|
||||
scriptSrc: ["'self'", "'strict-dynamic'"],
|
||||
connectSrc: ["'self'"],
|
||||
formAction: ["'none'"],
|
||||
}
|
||||
|
||||
return applyCspRulesWithNonce(response, cspRules)
|
||||
}
|
||||
|
|
|
@ -2,51 +2,51 @@ import type { ContentSecurityPolicyDirectives } from '$fresh/runtime.ts'
|
|||
import { getFreshNonce, toSnakeCase } from ':src/utils.ts'
|
||||
|
||||
export type CspRules = ContentSecurityPolicyDirectives & {
|
||||
upgradeInsecureRequests: true
|
||||
upgradeInsecureRequests: true
|
||||
}
|
||||
|
||||
export function applyCspRules(
|
||||
{ headers }: { headers: Headers },
|
||||
rules: CspRules,
|
||||
{ headers }: { headers: Headers },
|
||||
rules: CspRules,
|
||||
): void {
|
||||
const rulesString: string[] = []
|
||||
const rulesString: string[] = []
|
||||
|
||||
for (const rule in rules) {
|
||||
const value = rules[rule as unknown as keyof CspRules]
|
||||
const ruleName = toSnakeCase(rule)
|
||||
for (const rule in rules) {
|
||||
const value = rules[rule as unknown as keyof CspRules]
|
||||
const ruleName = toSnakeCase(rule)
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
rulesString.push(`${ruleName}`)
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
rulesString.push(`${ruleName}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
rulesString.push(`${ruleName} ${value.join(' ')}`)
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
rulesString.push(`${ruleName} ${value.join(' ')}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
rulesString.push(`${ruleName} ${value}`)
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
rulesString.push(`${ruleName} ${value}`)
|
||||
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(
|
||||
response: Response,
|
||||
rules: CspRules,
|
||||
response: Response,
|
||||
rules: CspRules,
|
||||
): Promise<void> {
|
||||
// Get nonce from any html response
|
||||
const nonce = await getFreshNonce(response)
|
||||
// Get nonce from any html response
|
||||
const nonce = await getFreshNonce(response)
|
||||
|
||||
// Add nonce to script src if defined
|
||||
if (nonce) {
|
||||
rules.scriptSrc?.push(`'nonce-${nonce}'`)
|
||||
}
|
||||
// Add nonce to script src if defined
|
||||
if (nonce) {
|
||||
rules.scriptSrc?.push(`'nonce-${nonce}'`)
|
||||
}
|
||||
|
||||
return applyCspRules(response, rules)
|
||||
return applyCspRules(response, rules)
|
||||
}
|
||||
|
|
|
@ -5,8 +5,8 @@ import type { MailAddress } from '@cohabit/resources-manager/types'
|
|||
// Import Datas
|
||||
import { exists } from '$std/fs/exists.ts'
|
||||
import { ensureDir } from '$std/fs/mod.ts'
|
||||
import groups from ":src/db/mock/groups.json" with { type: 'json' }
|
||||
import users from ":src/db/mock/users.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' }
|
||||
|
||||
await ensureDir('./cache')
|
||||
const dbPath = './cache/db.sqlite'
|
||||
|
|
|
@ -1,26 +1,26 @@
|
|||
import { FreshContext } from '$fresh/server.ts'
|
||||
|
||||
export function useSecurityHeaders(
|
||||
_request: Request,
|
||||
response: Response,
|
||||
_ctx: FreshContext,
|
||||
_request: Request,
|
||||
response: Response,
|
||||
_ctx: FreshContext,
|
||||
) {
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_strict_transport_security
|
||||
response.headers.set(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=63072000; includeSubDomains; preload',
|
||||
)
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Referrer_policy
|
||||
response.headers.set(
|
||||
'Referrer-Policy',
|
||||
'no-referrer, strict-origin-when-cross-origin',
|
||||
)
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/MIME_types
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff')
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Clickjacking
|
||||
response.headers.set('X-Frame-Options', 'DENY')
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CORP
|
||||
response.headers.set('Cross-Origin-Resource-Policy', 'same-origin')
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
|
||||
//? SRI plugin for non local resources only ?
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_strict_transport_security
|
||||
response.headers.set(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=63072000; includeSubDomains; preload',
|
||||
)
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Referrer_policy
|
||||
response.headers.set(
|
||||
'Referrer-Policy',
|
||||
'no-referrer, strict-origin-when-cross-origin',
|
||||
)
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/MIME_types
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff')
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Clickjacking
|
||||
response.headers.set('X-Frame-Options', 'DENY')
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CORP
|
||||
response.headers.set('Cross-Origin-Resource-Policy', 'same-origin')
|
||||
//See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
|
||||
//? SRI plugin for non local resources only ?
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { FreshContext } from '$fresh/server.ts'
|
||||
|
||||
export function useServiceworker(
|
||||
_request: Request,
|
||||
response: Response,
|
||||
ctx: FreshContext,
|
||||
_request: Request,
|
||||
response: Response,
|
||||
ctx: FreshContext,
|
||||
) {
|
||||
// Allow service worker to serve root scope
|
||||
if (ctx.url.pathname.endsWith('island-startserviceworker.js')) {
|
||||
response.headers.set('Service-Worker-Allowed', '/')
|
||||
}
|
||||
// Allow service worker to serve root scope
|
||||
if (ctx.url.pathname.endsWith('island-startserviceworker.js')) {
|
||||
response.headers.set('Service-Worker-Allowed', '/')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,51 +3,51 @@ import { SessionStore } from ':src/session/mod.ts'
|
|||
import { getCookies, setCookie } from 'jsr:@std/http@^0.224.4/cookie'
|
||||
|
||||
export function useSession(
|
||||
request: Request,
|
||||
response: Response,
|
||||
ctx: FreshContext,
|
||||
request: Request,
|
||||
response: Response,
|
||||
ctx: FreshContext,
|
||||
) {
|
||||
// Check if session already started
|
||||
if (SessionStore.getFromRequest(request) !== undefined) {
|
||||
return
|
||||
}
|
||||
// Check if session already started
|
||||
if (SessionStore.getFromRequest(request) !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear outdated cookies
|
||||
for (const cookie in getCookies(request.headers)) {
|
||||
setCookie(response.headers, {
|
||||
name: cookie,
|
||||
value: '',
|
||||
path: '/',
|
||||
expires: 0,
|
||||
})
|
||||
}
|
||||
// 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 }
|
||||
// Create new session
|
||||
const session = SessionStore.createSession()
|
||||
ctx.state = { ...ctx.state, session }
|
||||
|
||||
// Set session cookie
|
||||
setCookie(response.headers, {
|
||||
name: '__Secure-SESSION',
|
||||
value: session.uuid,
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict',
|
||||
secure: true,
|
||||
path: '/',
|
||||
expires: SessionStore.maxAge,
|
||||
})
|
||||
// Set session cookie
|
||||
setCookie(response.headers, {
|
||||
name: '__Secure-SESSION',
|
||||
value: session.uuid,
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict',
|
||||
secure: true,
|
||||
path: '/',
|
||||
expires: SessionStore.maxAge,
|
||||
})
|
||||
|
||||
// Set csrf
|
||||
const csrf = crypto.randomUUID()
|
||||
session.set('_csrf', csrf)
|
||||
// Set csrf
|
||||
const csrf = crypto.randomUUID()
|
||||
session.set('_csrf', csrf)
|
||||
|
||||
setCookie(response.headers, {
|
||||
name: '__Host-CSRF',
|
||||
value: csrf,
|
||||
httpOnly: false,
|
||||
sameSite: 'Strict',
|
||||
secure: true,
|
||||
path: '/',
|
||||
expires: SessionStore.maxAge,
|
||||
})
|
||||
setCookie(response.headers, {
|
||||
name: '__Host-CSRF',
|
||||
value: csrf,
|
||||
httpOnly: false,
|
||||
sameSite: 'Strict',
|
||||
secure: true,
|
||||
path: '/',
|
||||
expires: SessionStore.maxAge,
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue