├── .devcontainer.json ├── .dockerignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── deploy.yml │ ├── main.yml │ └── renew_certificates.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── docs └── screenshots │ ├── event.jpg │ ├── main.jpg │ └── test.jpg ├── fetch_certificates.js ├── netlify.toml ├── package-lock.json ├── package.json ├── src ├── app.html ├── assets │ ├── DCC.combined-schema.1.3.0.json │ ├── Digital_Green_Certificate_Signing_Keys.json │ ├── blacklist_text.json │ └── validity_data.json ├── global.d.ts ├── hooks.ts ├── lib │ ├── 2ddoc.ts │ ├── 2ddoc_check_signature.ts │ ├── TooltipFix.svelte │ ├── common_certificate_info.ts │ ├── database.ts │ ├── detect_certificate.ts │ ├── digital_green_certificate.ts │ ├── digital_green_certificate_types.ts │ ├── event.ts │ ├── file_types.ts │ ├── global_config.ts │ ├── http.ts │ ├── invitees.ts │ ├── random_key.ts │ ├── sha256.ts │ ├── storage.ts │ └── tac_verif_rules.ts ├── routes │ ├── LICENSE.svelte │ ├── _Certificate.svelte │ ├── _Certificate2ddocDetails.svelte │ ├── _Certificate2ddocTestInfo.svelte │ ├── _Certificate2ddocVaccineInfo.svelte │ ├── _CertificateDGCDetails.svelte │ ├── _CodeFound.svelte │ ├── _QrCodeVideoReader.svelte │ ├── __error.svelte │ ├── __layout.svelte │ ├── _invitedToStore.ts │ ├── _myWalletStore.ts │ ├── _showPromiseError.svelte │ ├── api-pass-sanitaire.svelte │ ├── api │ │ ├── borne │ │ │ └── [key].ts │ │ ├── create_event.ts │ │ ├── event-[private_code] │ │ │ ├── event.json.ts │ │ │ └── invite.json.ts │ │ ├── file │ │ │ ├── [key].ts │ │ │ └── config.json.ts │ │ ├── publicevent-[public_code] │ │ │ ├── event.json.ts │ │ │ └── invite.json.ts │ │ ├── test.ts │ │ └── validate_pass.ts │ ├── apropos.svelte │ ├── articles.svelte │ ├── borne │ │ ├── _connectionIndicator.svelte │ │ ├── _scan.svelte │ │ ├── _scan_stats_modal.svelte │ │ ├── _slideshow.svelte │ │ ├── _stats_chart.svelte │ │ ├── _stats_storage.ts │ │ ├── _validationMessage.svelte │ │ ├── config │ │ │ ├── _config.ts │ │ │ ├── _config_layout.svelte │ │ │ ├── _config_storage.ts │ │ │ ├── _external_request_config.svelte │ │ │ ├── _file_upload.svelte │ │ │ ├── _sound_picker.svelte │ │ │ ├── _tab_display.svelte │ │ │ ├── _tab_technical.svelte │ │ │ ├── custom-css-documentation.svelte │ │ │ ├── index.svelte │ │ │ ├── lecteur-physique.svelte │ │ │ └── simple.svelte │ │ ├── index.svelte │ │ └── statistiques.svelte │ ├── events │ │ ├── [eventId].svelte │ │ ├── _addInvitee.svelte │ │ ├── _communicate.svelte │ │ ├── _inviteesList.svelte │ │ ├── _my_events.svelte │ │ └── index.svelte │ ├── french-health-pass.svelte │ ├── fullscreen.svelte │ ├── import │ │ ├── __layout.svelte │ │ ├── file.svelte │ │ ├── text.svelte │ │ └── video.svelte │ ├── index.svelte │ ├── migration.svelte │ ├── offline.html.svelte │ ├── probleme-pass-sanitaire.svelte │ └── wallet.svelte ├── service-worker.ts └── types │ └── cbor-web │ └── index.d.ts ├── static ├── bong.mp3 ├── documentation │ ├── Description.png │ ├── Fond d'écran.png │ ├── Logo supérieur.png │ ├── Message affiché lorsqu'un passe est refusé.png │ ├── Message affiché lorsqu'un passe est valide.png │ ├── Message d'accueil.png │ ├── Médias affichés au dessus du message d'accueil.png │ └── Titre.png ├── favicon.ico ├── favicon │ ├── about.txt │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico ├── hein.mp3 ├── invalid.mp3 ├── mytest.png ├── plop.mp3 ├── robots.txt ├── sanipasse.svg ├── screenshot-qrscanner.png ├── security.txt ├── site.webmanifest ├── sitemap.xml ├── tin-lin.mp3 ├── tulut.mp3 └── valid.mp3 ├── svelte.config.js └── tsconfig.json /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/vscode/devcontainers/typescript-node:16", 3 | "remoteUser": "node", 4 | "forwardPorts": [3000] 5 | } 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .DS_Store 3 | node_modules 4 | /.svelte-kit 5 | /build 6 | /functions 7 | /sanipasse.db 8 | /docs 9 | LICENSE 10 | netlify.toml 11 | sanipasse.db 12 | README.md 13 | launch.json 14 | .github 15 | .vscode -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2019 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | }, 20 | rules: { 21 | '@typescript-eslint/no-unused-vars': [ 22 | 'warn', 23 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } 24 | ] 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: [v*] 7 | 8 | jobs: 9 | deploy: 10 | concurrency: deploy 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Setup repo 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - name: Deploy 19 | uses: dokku/github-action@db5e3b84461e5e73c56d8b0f6a67aab0df25256c 20 | with: 21 | git_remote_url: ssh://dokku@185.132.67.32/sanipasse 22 | ssh_private_key: ${{ secrets.DEPLOY_KEY }} 23 | push_to_registry: 24 | name: Push Docker image to Docker Hub 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Check out the repo 28 | uses: actions/checkout@v2 29 | - name: Set env 30 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 31 | - name: Set up QEMU 32 | uses: docker/setup-qemu-action@v1 33 | - name: Set up Docker Buildx 34 | id: buildx 35 | uses: docker/setup-buildx-action@v1 36 | - name: Log in to Docker Hub 37 | uses: docker/login-action@v1 38 | with: 39 | username: lovasoa 40 | password: ${{ secrets.DOCKER_PASSWORD }} 41 | - name: Build and Push to Docker Hub 42 | uses: docker/build-push-action@v2 43 | with: 44 | push: true 45 | tags: lovasoa/sanipasse:latest,lovasoa/sanipasse:${{ env.RELEASE_VERSION }} 46 | platforms: linux/amd64 47 | cache-from: type=gha 48 | cache-to: type=gha,mode=max 49 | - name: Build and Push to Docker Hub (all architectures) 50 | if: startsWith(github.ref, 'refs/tags/v') 51 | uses: docker/build-push-action@v2 52 | with: 53 | push: true 54 | tags: lovasoa/sanipasse:${{ env.RELEASE_VERSION }} 55 | platforms: linux/amd64,linux/arm64,linux/arm/v7 56 | cache-from: type=gha 57 | cache-to: type=gha,mode=max 58 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: npm test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: { node-version: '16' } 18 | - run: npm ci 19 | - run: npm test 20 | - run: npm run build 21 | - name: Check that the code was formatted with "npm run format" 22 | run: npx prettier --check --plugin-search-dir=. . 23 | -------------------------------------------------------------------------------- /.github/workflows/renew_certificates.yml: -------------------------------------------------------------------------------- 1 | name: Renew certificate list 2 | 3 | on: 4 | schedule: # every day at 3:37 and 13:37 UTC 5 | - cron: '37 3,13 * * *' 6 | push: 7 | paths: 8 | - '.github/workflows/renew_certificates.yml' 9 | - 'fetch_certificates.js' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: { node-version: '16' } 18 | - run: npm ci 19 | - run: npm run update-certificates 20 | env: 21 | TACV_TOKEN: ${{ secrets.TACV_TOKEN }} 22 | - run: npm test 23 | - uses: actions/upload-artifact@v2 24 | with: 25 | name: tacv_data.json 26 | path: /tmp/tacv_data.json 27 | - name: Create Pull Request 28 | uses: peter-evans/create-pull-request@6bb739433928fbc2bdc635d41105e124d0dce021 29 | with: 30 | base: master 31 | commit-message: Auto-update certificates from TAC-Verif 32 | branch: auto-certificate-updates 33 | title: '[Bot] Update Digital Green Certificate signing keys' 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /.svelte-kit 4 | /build 5 | /functions 6 | /sanipasse.db 7 | .env 8 | .vscode/settings.json 9 | *.mp4 10 | *.webm -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .svelte-kit/** 2 | static/** 3 | build/** 4 | node_modules/** 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "runtimeExecutable": "/usr/bin/chromium-browser", 9 | "name": "Launch Chrome", 10 | "request": "launch", 11 | "type": "pwa-chrome", 12 | "url": "http://localhost:3000", 13 | "webRoot": "${workspaceFolder}" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | WORKDIR /code 4 | 5 | # Need to install python for node-gyp on platforms without prebuilt binaries 6 | RUN case "$(arch)" in \ 7 | (*x86*) break;; \ 8 | *) \ 9 | apk add --update python3 make g++ musl-dev && \ 10 | ln -sf python3 /usr/bin/python;\ 11 | esac 12 | 13 | RUN echo update-notifier=false >> ~/.npmrc 14 | # Create a docker layer with only dependencies 15 | COPY package.json package-lock.json /code/ 16 | RUN npm ci 17 | COPY . /code 18 | 19 | # Build the code 20 | RUN SVELTEKIT_ADAPTER=node npm run build 21 | 22 | # You can mount /data on the host to persist data 23 | RUN mkdir /data 24 | RUN chown daemon:daemon /data 25 | 26 | ENV DATA_FOLDER='/data' 27 | ENV MAX_FILESIZE=5000000 28 | 29 | USER daemon 30 | 31 | # Listen on the port specified by the PORT environment variable, or 3000 by default 32 | EXPOSE 3000 33 | CMD ["node", "build"] 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sanipasse 2 | 3 | Application opensource de vérification de passeport sanitaire et d'organisation d'événements zéro-COVID. 4 | 5 | Cette application sait lire les codes 2D-DOC français ainsi que les "digital green certificates" européens (sous forme de QR code). 6 | 7 | ## Screenshots 8 | 9 | 10 | 11 |
12 | 13 | 14 |
15 | 16 | ## Héberger son instance Sanipasse 17 | 18 | La manière recommandée d'auto-héberger une instance de sanipasse est d'utiliser l'image docker officielle 19 | [lovasoa/sanipasse](https://hub.docker.com/r/lovasoa/sanipasse). 20 | 21 | L'image écoute en HTTP sur le port 3000 et stocke ses données persistentes dans le répertoire `/data/`. 22 | 23 | Pour lancer le service sur le port 80 sur un serveur, en conservant les données dans un 24 | [volume docker](https://docs.docker.com/storage/volumes/), on peut utiliser la commande suivante : 25 | 26 | ```bash 27 | docker run -d -p 80:3000 -v sanipasse_data:/data/ --name sanipasse --rm lovasoa/sanipasse 28 | ``` 29 | 30 | L'image expose uniquement un serveur HTTP, mais pour fonctionner correctement, l'application doit être servie en HTTPS. 31 | Il vous faudra donc mettre un place un reverse proxy ([nginx](https://www.nginx.com/), par exemple) 32 | et obtenir un certificat SSL (avec [letsencrypt](https://certbot.eff.org/lets-encrypt/sharedhost-nginx), par exemple). 33 | Une manière simple et automatisée de mettre cela en place sur un serveur personnel est d'utiliser [dokku](https://dokku.com/) 34 | avec [dokku-letsencrypt](https://github.com/dokku/dokku-letsencrypt#dokku-letsencrypt). 35 | 36 | ## Developing 37 | 38 | Sanipasse supports Node.js v16+. 39 | 40 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 41 | 42 | ```bash 43 | npm run dev 44 | 45 | # or start the server and open the app in a new browser tab 46 | npm run dev -- --open 47 | ``` 48 | 49 | ## Running 50 | 51 | Build the app 52 | 53 | ```bash 54 | SVELTEKIT_ADAPTER=node npm run build 55 | ``` 56 | 57 | Then run it: 58 | 59 | ```bash 60 | node build 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/screenshots/event.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lovasoa/sanipasse/ba0b2af510b1d506a56d240c7dc2d605f89f2884/docs/screenshots/event.jpg -------------------------------------------------------------------------------- /docs/screenshots/main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lovasoa/sanipasse/ba0b2af510b1d506a56d240c7dc2d605f89f2884/docs/screenshots/main.jpg -------------------------------------------------------------------------------- /docs/screenshots/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lovasoa/sanipasse/ba0b2af510b1d506a56d240c7dc2d605f89f2884/docs/screenshots/test.jpg -------------------------------------------------------------------------------- /fetch_certificates.js: -------------------------------------------------------------------------------- 1 | // Node.js script to refresh the accepted certificates list 2 | import base64 from 'base64-js'; 3 | import fetch from 'node-fetch'; 4 | import { X509Certificate, PublicKey } from '@peculiar/x509'; 5 | import crypto from 'isomorphic-webcrypto'; 6 | import fs from 'fs'; 7 | 8 | const OUTFILE = 'src/assets/Digital_Green_Certificate_Signing_Keys.json'; 9 | const ALL_DATA_FILE = '/tmp/tacv_data.json'; 10 | // Concatenated sh256 fingerprints of blacklisted certificates 11 | const BLACKLIST_FILE = 'src/assets/blacklist_text.json'; 12 | const ENDPOINT = 'https://portail.tacv.myservices-ingroupe.com'; 13 | 14 | async function main() { 15 | const TOKEN = process.env['TACV_TOKEN']; 16 | if (!TOKEN) 17 | return console.log( 18 | 'Missing environment variable TACV_TOKEN. ' + 19 | 'You can get the value of the token from the TousAntiCovid Verif application.' 20 | ); 21 | 22 | await Promise.all([handle_blacklist(TOKEN), handle_tacv_data(TOKEN)]); 23 | } 24 | 25 | async function handle_tacv_data(TOKEN) { 26 | const tacv_data = await get_data(TOKEN); 27 | await Promise.all([ 28 | save_tacv_data(tacv_data), 29 | save_validity_data(tacv_data), 30 | save_certs(tacv_data) 31 | ]); 32 | } 33 | 34 | async function handle_blacklist(TOKEN) { 35 | const promises = ['dcc', '2ddoc'].map((t) => get_blacklist(t, TOKEN)); 36 | const blacklists = await Promise.all(promises); 37 | await save_blacklist(blacklists.flat()); 38 | } 39 | 40 | async function save_tacv_data(tacv_data) { 41 | await fs.promises.writeFile(ALL_DATA_FILE, JSON.stringify(tacv_data)); 42 | console.log('Saved all data to ' + ALL_DATA_FILE); 43 | } 44 | 45 | async function save_certs(tacv_data) { 46 | const certs = await get_certs(tacv_data); 47 | const contents = JSON.stringify(certs, null, '\t') + '\n'; 48 | await fs.promises.writeFile(OUTFILE, contents); 49 | console.log(`Wrote ${Object.keys(certs).length} certificates to ${OUTFILE}`); 50 | } 51 | 52 | async function get_data(token) { 53 | const resp = await fetch(`${ENDPOINT}/api/client/configuration/synchronisation/tacv`, { 54 | headers: { Authorization: `Bearer ${token}` } 55 | }); 56 | if (resp.status !== 200) throw new Error(`API returned error: ${await resp.text()}`); 57 | console.log('Fetched validity and certificates data'); 58 | return await resp.json(); 59 | } 60 | 61 | async function save_validity_data(tacv_data) { 62 | const VALIDITY_DATA_FILE = 'src/assets/validity_data.json'; 63 | const validity = tacv_data.specificValues.validity; 64 | const sorted = Object.fromEntries(Object.entries(validity).sort(([a], [b]) => (a > b ? 1 : -1))); 65 | await writeNiceJson(sorted, VALIDITY_DATA_FILE); 66 | console.log('Saved validity data to ' + VALIDITY_DATA_FILE); 67 | } 68 | 69 | /** 70 | * @param {'dcc'|'2ddoc'} type blacklist type 71 | * @param {string} token JWT token 72 | * @returns {string[]} hex digest of blacklisted certificates 73 | */ 74 | async function get_blacklist(type, token) { 75 | const resp = await fetch(`${ENDPOINT}/api/client/configuration/blacklist/tacv/${type}/0`, { 76 | headers: { Authorization: `Bearer ${token}` } 77 | }); 78 | if (resp.status !== 200) throw new Error(`API returned error: ${await resp.text()}`); 79 | const { elements, _lastIndexBlacklist } = await resp.json(); 80 | console.log(`Fetched ${elements.length} blacklisted ${type} certificates`); 81 | return elements.flatMap(({ hash, active }) => (active ? [hash] : [])); 82 | } 83 | 84 | async function save_blacklist(blacklist) { 85 | await writeNiceJson(blacklist.join(' '), BLACKLIST_FILE); 86 | console.log(`Saved ${blacklist.length}-item blacklist to ${BLACKLIST_FILE}`); 87 | } 88 | 89 | async function writeNiceJson(data, filename) { 90 | const nice = JSON.stringify(data, null, '\t') + '\n'; 91 | return fs.promises.writeFile(filename, nice); 92 | } 93 | 94 | async function get_certs(tacv_data) { 95 | const entries = Object.entries(tacv_data.certificatesDCC); 96 | const parsed = await Promise.all( 97 | entries.map(async ([kid, cert]) => { 98 | return [kid, await parseCert(cert)]; 99 | }) 100 | ); 101 | const sorted = parsed 102 | .filter((cert) => !!cert) // Remove certificates that could not be decoded 103 | .sort(([_k1, a], [_k2, b]) => (a.subject < b.subject ? -1 : 1)); 104 | return Object.fromEntries(sorted); 105 | } 106 | async function parseCert(cert) { 107 | // Certs are doube-base64 encoded 108 | const raw = base64.toByteArray(cert); 109 | const pem = new TextDecoder().decode(raw); 110 | try { 111 | return await exportCertificate(pem); 112 | } catch (err) { 113 | // The server returns both certificates and raw public keys 114 | return await exportPublicAsCert(pem); 115 | } 116 | } 117 | 118 | /** 119 | * @param {string} pem base64-encoded PEM x509 certificate 120 | * @returns {Promise} 121 | */ 122 | async function exportCertificate(pem) { 123 | const x509cert = new X509Certificate(pem); 124 | 125 | // Export the certificate data. 126 | return { 127 | serialNumber: x509cert.serialNumber, 128 | subject: x509cert.subject, 129 | issuer: x509cert.issuer, 130 | notBefore: x509cert.notBefore.toISOString(), 131 | notAfter: x509cert.notAfter.toISOString(), 132 | signatureAlgorithm: x509cert.signatureAlgorithm.name, 133 | fingerprint: Buffer.from(await x509cert.getThumbprint(crypto)).toString('hex'), 134 | ...(await exportPublicKeyInfo(x509cert.publicKey)) 135 | }; 136 | } 137 | 138 | /** 139 | * Generate a DSC from a single public key without certificate information 140 | * @param {string} pem base64-encoded PEM x509 certificate 141 | * @returns {Promise} 142 | */ 143 | async function exportPublicAsCert(pem) { 144 | // Export the certificate data. 145 | return { 146 | serialNumber: '', 147 | subject: 'UNKNOWN', 148 | issuer: 'UNKNOWN', 149 | notBefore: '2020-01-01', 150 | notAfter: '2030-01-01', 151 | signatureAlgorithm: '', 152 | fingerprint: '', 153 | ...(await exportPublicKeyInfo(new PublicKey(pem))) 154 | }; 155 | } 156 | 157 | /** 158 | * @param {PublicKey} pubkey 159 | * @returns {Promise<{ 160 | * publicKeyAlgorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams; 161 | * publicKeyPem: string; 162 | * }>} 163 | */ 164 | async function exportPublicKeyInfo(publicKey) { 165 | const public_key = await publicKey.export(crypto); 166 | const spki = await crypto.subtle.exportKey('spki', public_key); 167 | 168 | // Export the certificate data. 169 | return { 170 | publicKeyAlgorithm: public_key.algorithm, 171 | publicKeyPem: Buffer.from(spki).toString('base64') 172 | }; 173 | } 174 | 175 | main(); 176 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | # Settings in the [build] context are global and are applied to all contexts 2 | # unless otherwise overridden by more specific contexts. 3 | [build] 4 | # Directory that contains the deploy-ready HTML files and assets generated by 5 | # the build. This is relative to the base directory if one has been set, or the 6 | # root directory if a base has not been set. This sample publishes the 7 | # directory located at the absolute path "root/project/build-output" 8 | publish = "build" 9 | 10 | # Default build command. 11 | command = "npm ci && npm run build" 12 | 13 | environment = { } 14 | 15 | [[redirects]] 16 | from = "/api/*" 17 | to = "https://sanipasse.ophir.dev/api/:splat" 18 | status = 200 19 | force = true 20 | [redirects.headers] 21 | X-From = "Netlify" 22 | 23 | # Serve index.html when a file is not found 24 | [[redirects]] 25 | from = "/*" 26 | to = "/index.html" 27 | status = 200 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanipasse", 3 | "version": "2.6.0", 4 | "scripts": { 5 | "dev": "svelte-kit dev", 6 | "build": "svelte-kit build", 7 | "preview": "svelte-kit preview", 8 | "lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", 9 | "format": "prettier --write --plugin-search-dir=. .", 10 | "typecheck": "tsc --noEmit", 11 | "test": "svelte-check --fail-on-hints && npm run typecheck", 12 | "update-certificates": "node ./fetch_certificates.js", 13 | "start": "SVELTEKIT_ADAPTER=node npm run build && node build" 14 | }, 15 | "devDependencies": { 16 | "@peculiar/x509": "^1.3.2", 17 | "@sveltejs/adapter-node": "next", 18 | "@sveltejs/adapter-static": "next", 19 | "@sveltejs/kit": "next", 20 | "@types/pako": "^1.0.1", 21 | "@typescript-eslint/eslint-plugin": "^4.19.0", 22 | "@typescript-eslint/parser": "^4.19.0", 23 | "cbor": "^7.0.5", 24 | "eslint": "^7.22.0", 25 | "eslint-config-prettier": "^8.1.0", 26 | "eslint-plugin-svelte3": "^3.2.1", 27 | "node-fetch": "^2.6.1", 28 | "prettier": "~2.2.1", 29 | "prettier-plugin-svelte": "^2.2.0", 30 | "svelte": "^3.38.3", 31 | "svelte-check": "^1.5.2", 32 | "svelte-preprocess": "^4.0.0", 33 | "tslib": "^2.3.0", 34 | "typescript": "^4.3.5" 35 | }, 36 | "type": "module", 37 | "dependencies": { 38 | "@ctrl/ts-base32": "^1.2.6", 39 | "@zxing/browser": "^0.0.10", 40 | "@zxing/library": "^0.18.5", 41 | "ajv": "^8.6.0", 42 | "apexcharts": "^3.28.1", 43 | "base45-ts": "^1.0.3", 44 | "base64-js": "^1.5.1", 45 | "bignumber.js": "^9.0.1", 46 | "bootstrap": "^5.0.1", 47 | "bootstrap-icons": "^1.5.0", 48 | "cosette": "^0.8.0", 49 | "localforage": "^1.9.0", 50 | "pako": "^2.0.3", 51 | "pdfjs-dist": "^2.7.570", 52 | "sequelize": "^6.6.2", 53 | "sqlite3": "^5.0.2", 54 | "sveltestrap": "^5.8.0", 55 | "tinyduration": "^3.2.2", 56 | "workbox-precaching": "^6.1.5", 57 | "workbox-recipes": "^6.1.5" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | %svelte.head% 12 | 13 | 14 | 15 |
%svelte.body%
16 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/assets/validity_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "health": { 3 | "testNegativePcrEndHour": 24, 4 | "testNegativeAntigenicEndHour": 24, 5 | "testNegativePrimoPcrEndHour": 24, 6 | "testNegativePrimoAntigenicEndHour": 24, 7 | "testPositivePcrStartDay": 11, 8 | "testPositivePcrEndDay": 183, 9 | "testPositiveAntigenicStartDay": 11, 10 | "testPositiveAntigenicEndDay": 183, 11 | "recoveryStartDay": 11, 12 | "recoveryEndDay": 183, 13 | "recoveryDelayMax": 183, 14 | "recoveryDelayMaxNV": 183, 15 | "recoveryDelayMaxPV": 183, 16 | "recoveryDelayMaxTV": 6000, 17 | "recoveryDelayMaxUnderAge": 183, 18 | "recoveryAcceptanceAgePeriod": "P18Y", 19 | "vaccineDelay": 7, 20 | "vaccineDelayMax": 221, 21 | "vaccineDelayMaxRecovery": 221, 22 | "vaccineDelayJanssen": 28, 23 | "vaccineDelayJanssen1": 28, 24 | "vaccineDelayJanssen2": 7, 25 | "vaccineDelayNovavax": 7, 26 | "vaccineDelayMaxJanssen": 68, 27 | "vaccineDelayMaxJanssen1": 68, 28 | "vaccineDelayMaxJanssen2": 129, 29 | "vaccineDelayMaxNovavax": 129, 30 | "vaccineDelayMaxRecoveryNovavax": 129, 31 | "vaccineBoosterDelay": 0, 32 | "vaccineBoosterDelayMax": 6000, 33 | "vaccineBoosterDelayMaxJanssen": 6000, 34 | "vaccineBoosterDelayMaxNovavax": 129, 35 | "vaccineBoosterDelayUnderAge": 0, 36 | "vaccineBoosterAge": 19, 37 | "vaccineBoosterAgePeriod": "P18Y1M", 38 | "vaccineBoosterDelayNew": 7, 39 | "vaccineBoosterDelayUnderAgeNew": 7, 40 | "vaccineBoosterToggleDate": "2021-12-21" 41 | }, 42 | "plus": { 43 | "recoveryStartDay": 11, 44 | "recoveryEndDay": 6000 45 | }, 46 | "recoveryEndDay": 183, 47 | "recoveryStartDay": 11, 48 | "testNegativeAntigenicEndHour": 24, 49 | "testNegativePcrEndHour": 24, 50 | "testNegativePrimoAntigenicEndHour": 24, 51 | "testNegativePrimoPcrEndHour": 24, 52 | "testPositiveAntigenicEndDay": 183, 53 | "testPositiveAntigenicStartDay": 11, 54 | "testPositivePcrEndDay": 183, 55 | "testPositivePcrStartDay": 11, 56 | "vaccine": { 57 | "testNegativePcrEndHour": 24, 58 | "testNegativeAntigenicEndHour": 24, 59 | "testNegativePrimoPcrEndHour": 24, 60 | "testNegativePrimoAntigenicEndHour": 24, 61 | "testPositivePcrStartDay": 11, 62 | "testPositivePcrEndDay": 183, 63 | "testPositiveAntigenicStartDay": 11, 64 | "testPositiveAntigenicEndDay": 183, 65 | "testAcceptanceAgePeriod": "P16Y", 66 | "recoveryStartDay": 11, 67 | "recoveryEndDay": 183, 68 | "recoveryDelayMax": 183, 69 | "recoveryDelayMaxNV": 183, 70 | "recoveryDelayMaxPV": 183, 71 | "recoveryDelayMaxTV": 6000, 72 | "recoveryDelayMaxUnderAge": 183, 73 | "recoveryAcceptanceAgePeriod": "P18Y", 74 | "vaccineDelay": 7, 75 | "vaccineDelayMax": 221, 76 | "vaccineDelayMaxRecovery": 221, 77 | "vaccineDelayJanssen": 28, 78 | "vaccineDelayJanssen1": 28, 79 | "vaccineDelayJanssen2": 7, 80 | "vaccineDelayNovavax": 7, 81 | "vaccineDelayMaxJanssen": 68, 82 | "vaccineDelayMaxJanssen1": 68, 83 | "vaccineDelayMaxJanssen2": 129, 84 | "vaccineDelayMaxNovavax": 129, 85 | "vaccineDelayMaxRecoveryNovavax": 129, 86 | "vaccineBoosterDelay": 0, 87 | "vaccineBoosterDelayMax": 6000, 88 | "vaccineBoosterDelayMaxJanssen": 6000, 89 | "vaccineBoosterDelayMaxNovavax": 129, 90 | "vaccineBoosterDelayUnderAge": 0, 91 | "vaccineBoosterAge": 19, 92 | "vaccineBoosterAgePeriod": "P18Y1M", 93 | "vaccineBoosterDelayNew": 7, 94 | "vaccineBoosterDelayUnderAgeNew": 7, 95 | "vaccineBoosterToggleDate": "2021-12-21" 96 | }, 97 | "vaccineBoosterAge": 19, 98 | "vaccineBoosterAgePeriod": "P18Y1M", 99 | "vaccineBoosterDelay": 0, 100 | "vaccineBoosterDelayMax": 6000, 101 | "vaccineBoosterDelayNew": 7, 102 | "vaccineBoosterDelayUnderAge": 0, 103 | "vaccineBoosterDelayUnderAgeNew": 7, 104 | "vaccineBoosterToggleDate": "2021-12-21", 105 | "vaccineDelay": 7, 106 | "vaccineDelayJanssen": 28, 107 | "vaccineDelayMax": 221, 108 | "vaccineDelayMaxJanssen": 68, 109 | "vaccineDelayMaxRecovery": 221, 110 | "vaccinePassStartDate": "2022-01-23" 111 | } 112 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module 'pdfjs-dist/build/pdf.worker.entry'; 4 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import type { Handle } from '@sveltejs/kit'; 2 | import { performance } from 'perf_hooks'; 3 | 4 | export const handle: Handle = async ({ event, resolve }) => { 5 | const { request, url } = event; 6 | const id = (Math.random() * 100) | 0; 7 | const start = performance.now(); 8 | const referer = request.headers.get('referer'); 9 | const referer_text = referer ? `(from: ${referer})` : ''; 10 | console.log(`${request.method} ${url.pathname} ${referer_text} [${id}]`); 11 | const response = await resolve(event); 12 | const ms = (performance.now() - start).toFixed(1); 13 | console.log(`${url.pathname}: ${response.status} (${ms} ms) [${id}]`); 14 | return response; 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/2ddoc_check_signature.ts: -------------------------------------------------------------------------------- 1 | import base64 from 'base64-js'; 2 | import { base32Decode } from '@ctrl/ts-base32'; 3 | import crypto from 'isomorphic-webcrypto'; 4 | 5 | const ALGO = { name: 'ECDSA', hash: 'SHA-256' }; 6 | const KEY_PARAMS = { name: 'ECDSA', namedCurve: 'P-256' }; 7 | 8 | const subtle = crypto?.subtle; 9 | 10 | async function key(key_b64: string): Promise { 11 | if (!subtle) return 'unsupported'; 12 | const key_bin = base64.toByteArray(key_b64); 13 | return subtle.importKey('spki', key_bin, KEY_PARAMS, false, ['verify']); 14 | } 15 | 16 | // Public keys from https://gitlab.inria.fr/stopcovid19/stopcovid-android/-/blob/master/stopcovid/src/main/assets/Config/config.json 17 | const PUB_KEYS = new Map([ 18 | [ 19 | 'AHP1', 20 | key( 21 | 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPnxJntwNwme9uHSasmGFFwdC0FWNEpucgzhjr+/AZ6UuTm3kL3ogEUAwKU0tShEVmZNK4/lM05h+0ZvtboJM/A==' 22 | ) 23 | ], 24 | [ 25 | 'AHP2', 26 | key( 27 | 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEOYUgmx8pKu0UbyqQ/kt4+PXSpUprkO2YLHmzzoN66XjDW0AnSzXorFPe556p73Vawqaoy3qQKDIDB62IBYWBuA==' 28 | ) 29 | ], 30 | [ 31 | 'AV01', 32 | key( 33 | 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1T9uG2bEP7uWND6RT/lJs2y787BkEJoRMMLXvqPKFFC3ckqFAPnFjbiv/odlWH04a1P9CvaCRxG31FMEOFZyXA==' 34 | ) 35 | ], 36 | [ 37 | 'AV02', 38 | key( 39 | 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3jL6zQ0aQj9eJHUw4VDHB9sMoviLVIlADnoBwC43Md8p9w655z2bDhYEEajQ2amQzt+eU7HdWrvqY23Do91Izg==' 40 | ) 41 | ] 42 | ]); 43 | 44 | async function ecdsa_verify( 45 | public_key: CryptoKey, 46 | signature: ArrayBufferLike, 47 | data: ArrayBufferLike 48 | ): Promise { 49 | const valid = await subtle.verify(ALGO, public_key, signature, data); 50 | if (!valid) throw new Error('🚨 Signature invalide; ce certificat est peut-être contrefait'); 51 | } 52 | 53 | export async function check_signature( 54 | data: string, 55 | public_key_id: string, 56 | signature_base32: string 57 | ): Promise { 58 | const public_key_promise = PUB_KEYS.get(public_key_id); 59 | if (!public_key_promise) 60 | throw new Error( 61 | `🤨 Certificat signé par une entité non reconnue ("${public_key_id}"); ` + 62 | `ce certificat est peut-être contrefait.` 63 | ); 64 | const public_key = await public_key_promise; 65 | if (public_key === 'unsupported') 66 | throw new Error('Votre navigateur ne sait pas vérifier les signatures électroniques'); 67 | const signature = base32Decode(signature_base32); 68 | const data_binary = new TextEncoder().encode(data); 69 | return ecdsa_verify(public_key, signature, data_binary); 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/TooltipFix.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | {#if target} 10 | 11 | {/if} 12 | -------------------------------------------------------------------------------- /src/lib/common_certificate_info.ts: -------------------------------------------------------------------------------- 1 | import type { Certificate2ddoc } from './2ddoc'; 2 | import type { DGC } from './digital_green_certificate'; 3 | 4 | export interface CommonVaccineInfo { 5 | type: 'vaccination'; 6 | vaccination_date: Date; 7 | prophylactic_agent: string; 8 | doses_received: number; 9 | doses_expected: number; 10 | } 11 | 12 | export interface CommonTestInfo { 13 | type: 'test'; 14 | test_date: Date; 15 | is_negative: boolean; 16 | test_type: string; 17 | /// Set to true if the test did not give a conclusive result. 18 | is_inconclusive: boolean; 19 | } 20 | 21 | export interface AllCommonInfo { 22 | first_name: string; 23 | last_name: string; 24 | date_of_birth: Date; 25 | code: string; 26 | /** sha256 fingerprint */ 27 | fingerprint: string; 28 | source: { format: 'dgc'; cert: DGC } | { format: '2ddoc'; cert: Certificate2ddoc }; 29 | } 30 | 31 | export type CommonCertificateInfo = AllCommonInfo & (CommonVaccineInfo | CommonTestInfo); 32 | -------------------------------------------------------------------------------- /src/lib/database.ts: -------------------------------------------------------------------------------- 1 | import SequelizePKG from 'sequelize'; 2 | const { Sequelize, STRING, DATE, BOOLEAN, Model } = SequelizePKG; 3 | import { generateKey } from '$lib/random_key'; 4 | import type { DBEvent, DBPerson } from '$lib/event'; 5 | import { DATABASE_CONNECTION_STRING } from './global_config'; 6 | 7 | const sequelize = new Sequelize(DATABASE_CONNECTION_STRING); 8 | 9 | class Event extends Model {} 10 | Event.init( 11 | { 12 | public_code: { 13 | type: STRING(9), 14 | primaryKey: true, 15 | defaultValue: generateKey 16 | }, 17 | private_code: { 18 | type: STRING(9), 19 | defaultValue: generateKey 20 | }, 21 | name: STRING, 22 | date: { type: DATE, allowNull: false } 23 | }, 24 | { 25 | sequelize, 26 | modelName: 'event', 27 | indexes: [{ unique: true, fields: ['private_code'] }] 28 | } 29 | ); 30 | 31 | interface ModelPerson extends DBPerson { 32 | eventPublicCode: string; 33 | } 34 | class Person extends Model {} 35 | Person.init( 36 | { 37 | key: { type: STRING, primaryKey: true }, 38 | eventPublicCode: { type: STRING, primaryKey: true }, 39 | validated: { type: BOOLEAN, allowNull: false, defaultValue: false }, 40 | invited: { type: BOOLEAN, allowNull: false, defaultValue: false } 41 | }, 42 | { 43 | sequelize, 44 | modelName: 'person' 45 | } 46 | ); 47 | Event.hasMany(Person, { foreignKey: 'eventPublicCode' }); 48 | Person.belongsTo(Event); 49 | 50 | export interface DBConfig { 51 | key: string; 52 | config: string; 53 | } 54 | class BorneConfig extends Model {} 55 | BorneConfig.init( 56 | { 57 | key: { type: STRING, primaryKey: true }, 58 | config: { type: STRING } 59 | }, 60 | { 61 | sequelize, 62 | modelName: 'config' 63 | } 64 | ); 65 | 66 | class ApiKeys extends Model<{ api_key: string; used_at: Date }> {} 67 | ApiKeys.init( 68 | { 69 | api_key: { type: STRING, primaryKey: true }, 70 | used_at: { type: DATE, primaryKey: true, allowNull: true } 71 | }, 72 | { 73 | sequelize, 74 | updatedAt: false, 75 | createdAt: false 76 | } 77 | ); 78 | 79 | const sync = sequelize.sync({}); 80 | const SyncedEvent = sync.then(() => Event); 81 | const SyncedPerson = sync.then(() => Person); 82 | const SyncedBorneConfig = sync.then(() => BorneConfig); 83 | const SyncedApiKeys = sync.then(() => ApiKeys); 84 | 85 | export type AsJson = T extends Date ? Date : { [K in keyof T]: AsJson }; 86 | 87 | export { 88 | SyncedEvent as Event, 89 | SyncedPerson as Person, 90 | SyncedBorneConfig as BorneConfig, 91 | SyncedApiKeys as ApiKeys 92 | }; 93 | -------------------------------------------------------------------------------- /src/lib/detect_certificate.ts: -------------------------------------------------------------------------------- 1 | import type { CommonCertificateInfo } from './common_certificate_info'; 2 | import { findCertificateError, validityInterval } from './tac_verif_rules'; 3 | 4 | export const DGC_PREFIX = 'HC1:'; 5 | 6 | function extractCodeFromLink(doc: string): string { 7 | doc = doc.trim(); 8 | let url: URL | undefined; 9 | try { 10 | url = new URL(doc); 11 | } catch (e) { 12 | /** ignore non-URL docs*/ 13 | } 14 | if (url && url.host === 'bonjour.tousanticovid.gouv.fr') { 15 | // The first TousAntiCovid QR codes used "/app/wallet?v=..." 16 | const v = url.searchParams.get('v'); 17 | if (v) return v; 18 | // The latest TousAntiCovid links use "/app/wallet2d#..." and "/app/walletdcc#..." 19 | else return decodeURIComponent(url.hash.slice(1)); 20 | } 21 | return doc; 22 | } 23 | 24 | export function isDGC(code: string): boolean { 25 | return code.startsWith(DGC_PREFIX); 26 | } 27 | 28 | /** 29 | * Detects the type of a certificate and parses it 30 | */ 31 | export async function parse_any(doc_or_link: string): Promise { 32 | const doc = extractCodeFromLink(doc_or_link); 33 | const { parse } = await import(isDGC(doc) ? './digital_green_certificate' : './2ddoc'); 34 | return await parse(doc); 35 | } 36 | 37 | export abstract class RuleSet { 38 | public abstract findCertificateError(c: CommonCertificateInfo, date: Date): string | undefined; 39 | public findCertificateErrorNow(c: CommonCertificateInfo): string | undefined { 40 | return this.findCertificateError(c, new Date()); 41 | } 42 | public checkCertificate(c: CommonCertificateInfo) { 43 | const date = new Date(); 44 | const error = this.findCertificateError(c, date); 45 | if (error != null) throw new Error(error); 46 | } 47 | } 48 | class TousAntiCovidRules extends RuleSet { 49 | constructor(public vaccinePass?: boolean) { 50 | super(); 51 | } 52 | findCertificateError(c: CommonCertificateInfo, date: Date): string | undefined { 53 | return findCertificateError(c, date, this.vaccinePass); 54 | } 55 | } 56 | 57 | export const PASS_VALIDITY_RULES = { 58 | tousAntiCovidDefaultRules: new TousAntiCovidRules(), 59 | tousAntiCovidVaccineRules: new TousAntiCovidRules(true), 60 | tousAntiCovidHealthRules: new TousAntiCovidRules(false) 61 | } as const; 62 | 63 | export type ValidityRuleName = keyof typeof PASS_VALIDITY_RULES; 64 | -------------------------------------------------------------------------------- /src/lib/digital_green_certificate_types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Types generated with: 3 | $ tmp_file=$(mktemp) 4 | $ npm run quicktype \ 5 | --src https://raw.githubusercontent.com/ehn-dcc-development/ehn-dcc-schema/release/1.3.0/DCC.combined-schema.json \ 6 | --src-lang schema \ 7 | --lang typescript \ 8 | --just-types \ 9 | --top-level HCert \ 10 | --out ${tmp_file} 11 | $ sed -i -e 's/: Date;/: string;/g' 12 | $ sed -i -e 's/interface HCERT/interface HCert/g' ${tmp_file} 13 | $ cat ${tmp_file} 14 | */ 15 | 16 | /** 17 | * EU Digital Covid Certificate 18 | */ 19 | export interface HCert { 20 | /** 21 | * Date of Birth of the person addressed in the DCC. ISO 8601 date format restricted to 22 | * range 1900-2099 or empty 23 | */ 24 | dob: string; 25 | /** 26 | * Surname(s), forename(s) - in that order 27 | */ 28 | nam: Nam; 29 | /** 30 | * Recovery Group 31 | */ 32 | r?: RElement[]; 33 | /** 34 | * Test Group 35 | */ 36 | t?: TElement[]; 37 | /** 38 | * Vaccination Group 39 | */ 40 | v?: VElement[]; 41 | /** 42 | * Version of the schema, according to Semantic versioning (ISO, https://semver.org/ version 43 | * 2.0.0 or newer) 44 | */ 45 | ver: string; 46 | } 47 | 48 | /** 49 | * Surname(s), forename(s) - in that order 50 | * 51 | * Person name: Surname(s), forename(s) - in that order 52 | */ 53 | export interface Nam { 54 | /** 55 | * The surname or primary name(s) of the person addressed in the certificate 56 | */ 57 | fn?: string; 58 | /** 59 | * The surname(s) of the person, transliterated ICAO 9303 60 | */ 61 | fnt: string; 62 | /** 63 | * The forename(s) of the person addressed in the certificate 64 | */ 65 | gn?: string; 66 | /** 67 | * The forename(s) of the person, transliterated ICAO 9303 68 | */ 69 | gnt?: string; 70 | } 71 | 72 | /** 73 | * Recovery Entry 74 | */ 75 | export interface RElement { 76 | /** 77 | * Unique Certificate Identifier, UVCI 78 | */ 79 | ci: string; 80 | /** 81 | * Country of Test 82 | */ 83 | co: string; 84 | /** 85 | * ISO 8601 complete date: Certificate Valid From 86 | */ 87 | df: string; 88 | /** 89 | * ISO 8601 complete date: Certificate Valid Until 90 | */ 91 | du: string; 92 | /** 93 | * ISO 8601 complete date of first positive NAA test result 94 | */ 95 | fr: string; 96 | /** 97 | * Certificate Issuer 98 | */ 99 | is: string; 100 | tg: string; 101 | } 102 | 103 | /** 104 | * Test Entry 105 | */ 106 | export interface TElement { 107 | /** 108 | * Unique Certificate Identifier, UVCI 109 | */ 110 | ci: string; 111 | /** 112 | * Country of Test 113 | */ 114 | co: string; 115 | /** 116 | * Certificate Issuer 117 | */ 118 | is: string; 119 | /** 120 | * RAT Test name and manufacturer 121 | */ 122 | ma?: string; 123 | /** 124 | * NAA Test Name 125 | */ 126 | nm?: string; 127 | /** 128 | * Date/Time of Sample Collection 129 | */ 130 | sc: string; 131 | /** 132 | * Testing Centre 133 | */ 134 | tc?: string; 135 | tg: string; 136 | /** 137 | * Test Result 138 | */ 139 | tr: string; 140 | /** 141 | * Type of Test 142 | */ 143 | tt: string; 144 | } 145 | 146 | /** 147 | * Vaccination Entry 148 | */ 149 | export interface VElement { 150 | /** 151 | * Unique Certificate Identifier: UVCI 152 | */ 153 | ci: string; 154 | /** 155 | * Country of Vaccination 156 | */ 157 | co: string; 158 | /** 159 | * Dose Number 160 | */ 161 | dn: number; 162 | /** 163 | * ISO8601 complete date: Date of Vaccination 164 | */ 165 | dt: string; 166 | /** 167 | * Certificate Issuer 168 | */ 169 | is: string; 170 | /** 171 | * Marketing Authorization Holder - if no MAH present, then manufacturer 172 | */ 173 | ma: string; 174 | /** 175 | * vaccine medicinal product 176 | */ 177 | mp: string; 178 | /** 179 | * Total Series of Doses 180 | */ 181 | sd: number; 182 | /** 183 | * disease or agent targeted 184 | */ 185 | tg: string; 186 | /** 187 | * vaccine or prophylaxis 188 | */ 189 | vp: string; 190 | } 191 | -------------------------------------------------------------------------------- /src/lib/event.ts: -------------------------------------------------------------------------------- 1 | export interface EventData { 2 | name: string; 3 | date: string; // ISO-8601 date string, serializable 4 | } 5 | 6 | export interface DBEvent { 7 | public_code?: string; 8 | private_code?: string; 9 | name: string; 10 | date: Date; 11 | } 12 | 13 | export interface EventWithPeople extends DBEvent { 14 | people: DBPerson[]; 15 | } 16 | 17 | export interface DBPerson { 18 | key: string; 19 | validated: boolean; 20 | invited?: boolean; 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/file_types.ts: -------------------------------------------------------------------------------- 1 | // No dynamic type should be allowed here: no svg 2 | 3 | export const main_types = ['image', 'video'] as const; 4 | export type MainType = typeof main_types[number]; 5 | 6 | export const ALLOWED_FILE_TYPES: Record = { 7 | jpg: 'image/jpeg', 8 | jpeg: 'image/jpeg', 9 | png: 'image/png', 10 | bmp: 'image/bmp', 11 | gif: 'image/gif', 12 | webp: 'image/webp', 13 | mp4: 'video/mp4', 14 | webm: 'video/webm' 15 | }; 16 | 17 | export function get_extension(name: string): string { 18 | const parts = name.split('.'); 19 | return parts[parts.length - 1].toLowerCase(); 20 | } 21 | 22 | function mime_allowed(mime: string, allowed: MainType[]) { 23 | return allowed.some((t) => mime.startsWith(t)); 24 | } 25 | export function file_of_type(file_name: string, allowed_types: MainType[]): boolean { 26 | const mime = mime_from_filename(file_name); 27 | if (!mime) return false; 28 | return mime_allowed(mime, allowed_types); 29 | } 30 | 31 | export function mime_from_filename(filename: string): string | undefined { 32 | const m = filename.match(/^data:([\w\/\+]+);/); 33 | return m ? m[1] : ALLOWED_FILE_TYPES[get_extension(filename)]; 34 | } 35 | 36 | export function main_type_from_filename(filename: string): MainType | undefined { 37 | const mime = mime_from_filename(filename); 38 | if (!mime) return undefined; 39 | const parts = mime.split('/'); 40 | return main_types.find((t) => t == parts[0]); 41 | } 42 | 43 | export function extensions_for_types(types: MainType[]): string[] { 44 | return Object.entries(ALLOWED_FILE_TYPES).flatMap(([ext, typ]) => 45 | mime_allowed(typ, types) ? ['.' + ext] : [] 46 | ); 47 | } 48 | 49 | export function mimes_for_types(types: MainType[]): string[] { 50 | return Object.entries(ALLOWED_FILE_TYPES).flatMap(([_, typ]) => 51 | mime_allowed(typ, types) ? [typ] : [] 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/global_config.ts: -------------------------------------------------------------------------------- 1 | // Items here are available both in the frontend and the backend. Don't put anything secret 2 | // Where to persist data 3 | export const DATA_FOLDER = process.env['DATA_FOLDER'] || '.'; 4 | // How to connect to the database 5 | export const DATABASE_CONNECTION_STRING = 6 | process.env['DATABASE_CONNECTION_STRING'] || `sqlite:${DATA_FOLDER}/sanipasse.db`; 7 | // Maximum file size 8 | export const MAX_FILESIZE = Number(process.env['MAX_FILESIZE'] || 5_000_000); 9 | -------------------------------------------------------------------------------- /src/lib/http.ts: -------------------------------------------------------------------------------- 1 | import { base } from '$app/paths'; 2 | 3 | export async function request( 4 | method: string, 5 | expected_status: number, 6 | path: string, 7 | data?: unknown 8 | ): Promise { 9 | const headers = data ? { 'Content-Type': 'application/json' } : undefined; 10 | const body = data ? JSON.stringify(data) : undefined; 11 | const r = await fetch(base + path, { method, headers, body }); 12 | if (r.status !== expected_status) { 13 | throw new Error(`${r.statusText}: ${await r.text()}`); 14 | } 15 | if (r.status !== 204) return r.json(); 16 | else return {} as T; 17 | } 18 | 19 | export async function put(path: string, data: unknown): Promise { 20 | return request('PUT', 201, path, data); 21 | } 22 | 23 | export async function get(path: string, data?: Record): Promise { 24 | const params = new URLSearchParams(data).toString(); 25 | if (params) path = path + '?' + params; 26 | return request('GET', 200, path); 27 | } 28 | 29 | export async function http_delete(path: string, data: unknown): Promise { 30 | return request('DELETE', 204, path, data); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/invitees.ts: -------------------------------------------------------------------------------- 1 | import type { DBPerson } from './event'; 2 | export type Invitee = DBPerson; 3 | export type Key = string; 4 | 5 | export function normalize(str: string, trim = true): string { 6 | if (trim) str = str.trim(); 7 | return str 8 | .toLowerCase() 9 | .normalize('NFD') 10 | .replace(/[\u0300-\u036f]/g, '') 11 | .replace(/[^a-z]/g, ' ') 12 | .substr(0, 80); 13 | } 14 | 15 | export interface Names { 16 | first_name: string; 17 | last_name: string; 18 | } 19 | export interface NamedInvitee extends Names, DBPerson {} 20 | 21 | export function getKey(i: Names): Key { 22 | return `${normalize(i.first_name)}|${normalize(i.last_name)}`; 23 | } 24 | 25 | export function parseKey(k: Key): { first_name: string; last_name: string } { 26 | const [first_name, last_name] = k.split('|', 2); 27 | return { first_name, last_name }; 28 | } 29 | 30 | export class Invitees { 31 | map: Map; 32 | constructor() { 33 | this.map = new Map(); 34 | } 35 | add(i: DBPerson): Invitees { 36 | this.map.set(i.key, i); 37 | return this; 38 | } 39 | invite(i: Names): Invitees { 40 | return this.add({ 41 | key: getKey(i), 42 | invited: true, 43 | validated: false 44 | }); 45 | } 46 | delete(key: Key): Invitees { 47 | this.map.delete(key); 48 | return this; 49 | } 50 | filtered(search: string): NamedInvitee[] { 51 | const search_terms = search.split(/\s+/).map((term) => term.toLocaleLowerCase()); 52 | const result: NamedInvitee[] = []; 53 | for (const [key, person] of this.map) { 54 | const names = parseKey(key); 55 | if ( 56 | !search_terms.length || 57 | search_terms.some( 58 | (term) => 59 | names.first_name.toLowerCase().includes(term) || 60 | names.last_name.toLowerCase().includes(term) 61 | ) 62 | ) 63 | result.push({ ...names, ...person }); 64 | } 65 | return result.sort((a, b) => (a.last_name > b.last_name ? 1 : -1)); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/random_key.ts: -------------------------------------------------------------------------------- 1 | import base64 from 'base64-js'; 2 | import crypto from 'isomorphic-webcrypto'; 3 | 4 | export function generateKey(byte_length = 9): string { 5 | const bytes = crypto.getRandomValues(new Uint8Array(byte_length)); 6 | return base64.fromByteArray(bytes).replace(/[/]/g, '-').replace(/\+/g, '_'); 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/sha256.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'isomorphic-webcrypto'; 2 | 3 | export async function sha256(i: string): Promise { 4 | const input_bytes = new TextEncoder().encode(i); 5 | const digest_bytes = await crypto.subtle.digest('SHA-256', input_bytes); 6 | return hex(digest_bytes); 7 | } 8 | 9 | function hex(i: ArrayBuffer): string { 10 | return [...new Uint8Array(i)].map((n) => n.toString(16).padStart(2, '0')).join(''); 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/storage.ts: -------------------------------------------------------------------------------- 1 | const localforage = import('localforage').then(async (localforage) => { 2 | // workaround for https://github.com/localForage/localForage/issues/1038 3 | if (typeof window === 'object') await localforage.default.ready(); 4 | return localforage.default; 5 | }); 6 | 7 | async function ensure_persisted() { 8 | if (typeof navigator !== 'object' || !navigator.storage || !navigator.storage.persist) 9 | return false; 10 | return await navigator.storage.persist(); 11 | } 12 | 13 | export async function storage_is_volatile(): Promise { 14 | if (typeof navigator !== 'object' || !navigator.storage || !navigator.storage.persisted) 15 | return undefined; 16 | return !(await navigator.storage.persisted()); 17 | } 18 | 19 | export async function storage_usage_ratio(): Promise { 20 | if (typeof navigator !== 'object' || !navigator.storage || !navigator.storage.estimate) 21 | return undefined; 22 | const { quota, usage } = await navigator.storage.estimate(); 23 | if (!quota || !usage) return undefined; 24 | return usage / quota; 25 | } 26 | 27 | export async function store_locally(key: string, value: any): Promise { 28 | if (typeof window !== 'object') return false; 29 | const persisted = await ensure_persisted(); 30 | const storage = await localforage; 31 | await storage.setItem(key, value); 32 | return persisted; 33 | } 34 | 35 | export async function get_from_local_store(key: string): Promise { 36 | if (typeof window !== 'object') return undefined; // Browser-only 37 | const storage = await localforage; 38 | const element = await storage.getItem(key); 39 | return element as T; 40 | } 41 | 42 | export async function create_storage_instance(name: string): Promise { 43 | const storage = await localforage; 44 | return storage.createInstance({ name }); 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/tac_verif_rules.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CommonCertificateInfo, 3 | CommonTestInfo, 4 | CommonVaccineInfo 5 | } from './common_certificate_info'; 6 | import validity_data from '../assets/validity_data.json'; // Constants containing the rules for the verification of the certificate 7 | import { parse as parse_period } from 'tinyduration'; 8 | import type { Duration as Period } from 'tinyduration'; 9 | import blacklist_text from '../assets/blacklist_text.json'; 10 | const blacklist_set = new Set(blacklist_text.split(' ')); 11 | 12 | const JANSSEN = 'EU/1/20/1525'; 13 | const PCR_TESTS = new Set(['943092', '945006', '948455', 'LP6464-4']); 14 | const ANTIGENIC_TESTS = new Set(['945584', 'LP217198-3']); 15 | 16 | type RuleData = typeof validity_data.health | typeof validity_data.vaccine; 17 | 18 | function add_period(date: Date, duration: Period): Date { 19 | const m = duration.negative ? -1 : 1; 20 | const d = new Date(date); 21 | if (duration.years) d.setFullYear(d.getFullYear() + duration.years * m); 22 | if (duration.months) d.setMonth(d.getMonth() + duration.months * m); 23 | if (duration.days) d.setDate(d.getDate() + duration.days * m); 24 | if (duration.hours) d.setHours(d.getHours() + duration.hours * m); 25 | if (duration.minutes) d.setMinutes(d.getMinutes() + duration.minutes * m); 26 | if (duration.seconds) d.setSeconds(d.getSeconds() + duration.seconds * m); 27 | return d; 28 | } 29 | 30 | function add_hours(date: Date, hours: number): Date { 31 | return add_period(date, { hours }); 32 | } 33 | 34 | function add_days(date: Date, days: number): Date { 35 | return add_period(date, { days }); 36 | } 37 | 38 | export class ValidityPeriod { 39 | start: Date; 40 | end: Date; 41 | constructor(start: Date, end: Date) { 42 | this.start = start; 43 | this.end = end; 44 | } 45 | } 46 | 47 | function testValidityInterval( 48 | test: CommonTestInfo, 49 | date_of_birth: Date, 50 | v: RuleData 51 | ): ValidityPeriod { 52 | const { test_date, is_negative, is_inconclusive, test_type } = test; 53 | const is_pcr = PCR_TESTS.has(test_type); 54 | const is_antigenic = ANTIGENIC_TESTS.has(test_type); 55 | if (!is_pcr && !is_antigenic) throw new Error(`Type de test inconnu: ${test_type}`); 56 | const is_positive = !is_negative && !is_inconclusive; 57 | 58 | if (is_negative) { 59 | const duration = is_pcr ? v.testNegativePcrEndHour : v.testNegativeAntigenicEndHour; 60 | let start = test_date; 61 | let end = add_hours(test_date, duration); 62 | if ('testAcceptanceAgePeriod' in v) { 63 | const period = parse_period(v.testAcceptanceAgePeriod); 64 | const end_accept = add_period(date_of_birth, period); 65 | end = end < end_accept ? end : end_accept; 66 | } 67 | if (end < start) throw new Error('Passe vaccinal: les tests ne sont plus acceptés'); 68 | return new ValidityPeriod(start, end); 69 | } else if (is_positive) { 70 | const start_days = is_pcr ? v.testPositivePcrStartDay : v.testPositiveAntigenicStartDay; 71 | const end_days = is_pcr ? v.testPositivePcrEndDay : v.testPositiveAntigenicEndDay; 72 | const start = add_days(test_date, start_days); 73 | const end = add_days(test_date, end_days); 74 | return new ValidityPeriod(start, end); 75 | } 76 | throw new Error('Test non conclusif'); 77 | } 78 | 79 | function vaccinationValidityInterval( 80 | vac: CommonVaccineInfo, 81 | date_of_birth: Date, 82 | v: RuleData 83 | ): ValidityPeriod { 84 | const { vaccination_date, prophylactic_agent, doses_expected, doses_received } = vac; 85 | if (doses_received < doses_expected) 86 | throw new Error(`Cycle vaccinal incomplet: dose ${doses_received} sur ${doses_expected}`); 87 | const vaccine: string = prophylactic_agent.toUpperCase().trim(); 88 | if (vaccine === JANSSEN) { 89 | const start = add_days(vaccination_date, v.vaccineDelayJanssen); 90 | const end = add_days(vaccination_date, v.vaccineDelayMaxJanssen); 91 | return new ValidityPeriod(start, end); 92 | } 93 | // Date at which the patient will have (or had) the age for a booster shot 94 | const VACCINE_BOOSTER_AGE_PERIOD = parse_period(v.vaccineBoosterAgePeriod); 95 | const booster_date = add_period(date_of_birth, VACCINE_BOOSTER_AGE_PERIOD); 96 | const is_under_age = add_days(vaccination_date, v.vaccineBoosterDelayUnderAge) < booster_date; 97 | const toggle_date = new Date(v.vaccineBoosterToggleDate); 98 | const delays = is_under_age 99 | ? [v.vaccineBoosterDelay, v.vaccineBoosterDelayNew] 100 | : [v.vaccineBoosterDelayUnderAge, v.vaccineBoosterDelayUnderAgeNew]; 101 | const booster_delay = delays[vaccination_date < toggle_date ? 0 : 1]; 102 | const start_days = doses_expected <= 2 ? v.vaccineDelay : booster_delay; 103 | const start = add_days(vaccination_date, start_days); 104 | const over_age_max_delay = 105 | doses_expected <= 2 && doses_received == doses_expected 106 | ? v.vaccineDelayMax 107 | : v.vaccineBoosterDelayMax; 108 | const over_age_end = add_days(vaccination_date, over_age_max_delay); 109 | const end = over_age_end < booster_date ? booster_date : over_age_end; 110 | return new ValidityPeriod(start, end); 111 | } 112 | 113 | export function validityInterval( 114 | cert: CommonCertificateInfo, 115 | vaccinePass?: boolean 116 | ): ValidityPeriod | { invalid: string } { 117 | if (vaccinePass === undefined) 118 | vaccinePass = new Date(validity_data.vaccinePassStartDate) < new Date(); 119 | const v = vaccinePass ? validity_data.vaccine : validity_data.health; 120 | const { type, date_of_birth } = cert; 121 | try { 122 | return type === 'test' 123 | ? testValidityInterval(cert, date_of_birth, v) 124 | : vaccinationValidityInterval(cert, date_of_birth, v); 125 | } catch (e) { 126 | return { invalid: e instanceof Error ? e.message : `${e}` }; 127 | } 128 | } 129 | 130 | export function findCertificateError( 131 | c: CommonCertificateInfo, 132 | target_date?: Date, 133 | vaccinePass?: boolean 134 | ): string | undefined { 135 | if (target_date === undefined) target_date = new Date(); 136 | if (blacklist_set.has(c.fingerprint)) 137 | return 'Ce certificat est sur liste noire. Il est probablement frauduleux.'; 138 | const validity = validityInterval(c, vaccinePass); 139 | if ('invalid' in validity) return validity.invalid; 140 | const { start, end } = validity; 141 | const err_msg = `La validité de ce certificat de ${c.type}`; 142 | if (start > target_date) return `${err_msg} commence le ${start.toLocaleDateString('fr')}.`; 143 | if (end < target_date) return `${err_msg} se termine le ${end.toLocaleDateString('fr')}.`; 144 | } 145 | -------------------------------------------------------------------------------- /src/routes/_Certificate.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | {#if with_fullscreen} 17 | 18 | 19 | 20 | {/if} 21 | 22 |
23 | {info.type === 'vaccination' ? '💉' : '🧪'} 24 |
25 | 26 |

27 | {info.type === 'vaccination' 28 | ? 'Vaccin' 29 | : info.type === 'test' 30 | ? 'Test de dépistage' 31 | : 'Certificat de rétablissement'} 32 |

33 |

34 | 👤 35 | {info.first_name.toLocaleLowerCase()} 36 | {info.last_name} 37 |

38 |

🎂 Né(e) le {info.date_of_birth.toLocaleDateString('fr')}

39 | {#if !error && 'end' in validity && Date.now() + 1000 * 3600 * 24 * 365 > validity.end.getTime()} 40 | 41 |

📅 Certificat valide jusqu'au {validity.end.toLocaleDateString('fr')}

42 | {/if} 43 | 44 |
45 | 46 | {#if error} 47 |

⚠️ {error}

48 | {/if} 49 |
50 | {#if source.format === '2ddoc'} 51 | 52 | {:else} 53 | 54 | {/if} 55 |
56 |
57 |
58 | 59 | 71 | -------------------------------------------------------------------------------- /src/routes/_Certificate2ddocDetails.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | Informations générales 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 28 | 29 | 30 | 31 | 36 | 37 | 38 | 39 | 43 | 44 | 45 | 46 | 50 | 51 | 52 | 53 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
Version 2D-Doc{certificate.document_version}
Date de création{certificate.creation_date 25 | ? certificate.creation_date.toLocaleDateString('fr-FR') 26 | : ' - '}
Date de signature{certificate.signature_date 33 | ? certificate.signature_date.toLocaleDateString('fr-FR') 34 | : ' - '}
Autorité de certification{getCertificateAuthority(certificate.certificate_authority_id)} 41 | ({certificate.certificate_authority_id})
Clé de chiffrement{getPublicKey(certificate.public_key_id)} 48 | ({certificate.public_key_id})
Type de document{certificate.document_type == 'B2' 55 | ? 'Résultat de test virologique' 56 | : 'Attestation vaccinale'} ({certificate.document_type})
Périmètre{certificate.document_perimeter}
Pays émetteur{certificate.document_country}
69 |
70 |
71 | 72 | {#if 'vaccinated_first_name' in certificate} 73 | 74 | {:else} 75 | 76 | {/if} 77 | 78 | 79 | 80 | Données brutes 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 |
Code 2D-Doc{certificate.code}
91 |
92 |
93 | 94 | 99 | -------------------------------------------------------------------------------- /src/routes/_Certificate2ddocTestInfo.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | Certificat de dépistage 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 46 | 47 | 48 | 49 | 50 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
Prénom(s){certificate.tested_first_name}
Nom{certificate.tested_last_name}
Date de naissance{certificate.tested_birth_date.toLocaleDateString('fr-FR')}
Genre{getSex(certificate.sex)} ({certificate.sex})
Code LOINC 44 | {certificate.analysis_code}
Résultat de l'analyse{getAnalysisResult(certificate.analysis_result)} 52 | ({certificate.analysis_result})
Date prélèvement{certificate.analysis_datetime.toLocaleDateString('fr-FR')}
62 |
63 |
64 | -------------------------------------------------------------------------------- /src/routes/_Certificate2ddocVaccineInfo.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | Certificat de vaccination 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 74 | 75 | 76 |
Prénom(s){certificate.vaccinated_first_name}
Nom{certificate.vaccinated_last_name}
Date de naissance{certificate.vaccinated_birth_date.toLocaleDateString('fr-FR')}
Maladie couverte{certificate.disease}
Agent prophylactique{certificate.prophylactic_agent}
Nom du vaccin{certificate.vaccine}
Fabricant du vaccin{certificate.vaccine_maker}
Doses reçues{certificate.doses_received}
Doses attendues{certificate.doses_expected}
Dernière dose le{certificate.last_dose_date.toLocaleDateString('fr-FR')}
Vaccination{certificate.cycle_state === 'TE' ? 'Terminée' : 'En cours'} 72 | ({certificate.cycle_state})
77 |
78 |
79 | -------------------------------------------------------------------------------- /src/routes/_CodeFound.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | {#if error} 41 |
42 | 43 |

Erreur

44 |
{error}
45 |
46 |
47 | {:else if status === 'decoding'} 48 | Décodage du certificat... 49 | {:else if codeFound && info} 50 | 51 | {#if $invitedTo.eventId} 52 | Confirmer ma présence 53 | {/if} 54 | 55 | 56 | 57 | 58 | {#if status === 'validated'} 59 | 63 | {/if} 64 | 65 | 66 | 67 | {#if $invitedTo.eventId} 68 | {#if status === 'notsent'} 69 | 76 |
77 |

78 | Votre certificat ne sera pas stocké par Sanipasse, ni visible par l'organisateur de 79 | l'événement {$invitedTo.event?.name || ''}. 80 |

81 | {:else if status === 'sending'} 82 | 86 | {/if} 87 | {:else if $wallet.includes(info.code)} 88 | 92 | {:else} 93 | 97 |

98 | Votre carnet de test est enregistré localement sur votre appareil et n'est pas envoyé sur 99 | les serveurs de sanipasse. Il est disponible depuis la page d'accueil. 100 |

101 | {/if} 102 |
103 |
104 | {/if} 105 | -------------------------------------------------------------------------------- /src/routes/_QrCodeVideoReader.svelte: -------------------------------------------------------------------------------- 1 | 77 | 78 |
79 | 80 |
90 | 91 | {#await decodePromise} 92 |
Chargement de la caméra...
93 | {:catch error} 94 |
95 |

Impossible d'accéder à la caméra

96 | {error} 97 | 98 |

99 | Votre caméra fonctionne normalement sur d'autres sites et applications, vous avez bien 100 | autorisé sanipasse à y accéder, et vous pensez que c'est un bug dans sanipasse ? Vous pouvez ouvrir un rapport de bug. 106 |

107 |
108 | {/await} 109 | 110 | 151 | -------------------------------------------------------------------------------- /src/routes/__error.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 22 | 23 |
24 |

Erreur {status}

25 |
{error.name} : {error.message}
26 |

Vous pensez que ceci est un bug dans sanipasse ?

27 |

28 | Vous pouvez ouvrir un rapport de bug. 39 |

40 |
41 | -------------------------------------------------------------------------------- /src/routes/__layout.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 45 | 46 | 47 | Sanipasse - vérification de pass sanitaire 48 | {#if canonical} 49 | 50 | {/if} 51 | 52 | 53 | {#if !hide_menu} 54 | 55 | 56 | 57 | Sanipasse 58 | 59 | (isOpen = !isOpen)} class="me-2" /> 60 | 61 | 74 | 75 | 76 | {/if} 77 | 78 | (canonical = undefined)}> 79 | Note importante 80 | 81 |

82 | sanipasse.fr migre vers 83 | sanipasse.ophir.dev 84 |

85 |

Plus d'informations

86 |
87 |
88 | 89 |
90 | 91 | 92 | 93 |
94 | 95 | 112 | -------------------------------------------------------------------------------- /src/routes/_invitedToStore.ts: -------------------------------------------------------------------------------- 1 | import type { DBEvent } from '$lib/event'; 2 | import { get } from '$lib/http'; 3 | import { writable } from 'svelte/store'; 4 | 5 | export interface InvitedTo { 6 | eventId?: string; 7 | event?: DBEvent; 8 | promise: Promise; 9 | } 10 | function createInvitedToStore() { 11 | const eventId = globalThis?.location?.hash?.slice(1); 12 | const promise: Promise = 13 | eventId && typeof window === 'object' 14 | ? get(`/api/publicevent-${eventId}/event.json`) 15 | : new Promise((accept) => { 16 | if (!eventId) accept(undefined); 17 | }); 18 | const { subscribe, update } = writable({ eventId, promise }); 19 | promise.then((event) => { 20 | update((invitedTo) => ({ ...invitedTo, event })); 21 | }); 22 | return { subscribe }; 23 | } 24 | 25 | const invitedTo = createInvitedToStore(); 26 | export default invitedTo; 27 | -------------------------------------------------------------------------------- /src/routes/_myWalletStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import { get_from_local_store, store_locally } from '$lib/storage'; 3 | 4 | const WALLET_STORE_KEY = 'wallet'; 5 | 6 | async function walletSet(value: string[]) { 7 | await store_locally(WALLET_STORE_KEY, value); 8 | } 9 | 10 | async function walletGet(): Promise { 11 | return (await get_from_local_store(WALLET_STORE_KEY)) || []; 12 | } 13 | 14 | function createWalletStore() { 15 | const { subscribe, set, update } = writable([]); 16 | 17 | walletGet().then((w) => set(w)); 18 | return { 19 | subscribe, 20 | add: (cert: string) => { 21 | update((wallet) => { 22 | const newWallet = [...wallet, cert]; 23 | walletSet(newWallet); 24 | return newWallet; 25 | }); 26 | }, 27 | remove: (cert: string) => { 28 | update((wallet) => { 29 | const newWallet = wallet.filter((c) => c !== cert); 30 | walletSet(newWallet); 31 | return newWallet; 32 | }); 33 | }, 34 | favorite: (cert: string) => { 35 | update((wallet) => { 36 | const newWallet = [cert, ...wallet.filter((c) => c !== cert)]; 37 | walletSet(newWallet); 38 | return newWallet; 39 | }); 40 | } 41 | }; 42 | } 43 | 44 | const wallet = createWalletStore(); 45 | export default wallet; 46 | -------------------------------------------------------------------------------- /src/routes/_showPromiseError.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | {#await promise} 6 |