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
class='components__auto_grid'
style={{
gridTemplateColumns:
`repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
gridTemplateColumns: `repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
...style,
}}
>

View file

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

View file

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

@ -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'),
},
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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', '/')
}
}

View file

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