Skip to content
This repository has been archived by the owner on Apr 5, 2024. It is now read-only.

feat: e2esdk [DO NOT MERGE] #169

Draft
wants to merge 8 commits into
base: hasura
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -44,4 +44,7 @@ cypress/videos
cypress/screenshots

# Robots.txt
robots.txt
robots.txt

# local volume
.storage
17 changes: 17 additions & 0 deletions .kontinuous/values.yaml
Original file line number Diff line number Diff line change
@@ -18,6 +18,15 @@ app:
imagePackage: app
containerPort: 3000
probesPath: "/healthz"
volumes:
- name: uploads
emptyDir: {}
volumeMounts:
- name: uploads
mountPath: /uploads
env:
- name: STORAGE_DIR
value: /uploads
envFrom:
- configMapRef:
name: app
@@ -148,6 +157,10 @@ keycloakx:
- name: compile-realm
image: hairyhenderson/gomplate:v3.10.0-alpine
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 100
volumeMounts:
- name: keycloak-realm-tpl
mountPath: "/realm-tpl/"
@@ -170,6 +183,10 @@ keycloakx:
- "cat /realm-tpl/realm.json.envtpl | gomplate > /realm/realm.json"
- name: fetch-keycloak-providers
image: curlimages/curl
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 405
imagePullPolicy: IfNotPresent
command:
- sh
270 changes: 179 additions & 91 deletions .talismanrc

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions csp.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
const ContentSecurityPolicy = `
default-src 'self' *.fabrique.social.gouv.fr;
img-src 'self' data: *.fabrique.social.gouv.fr https://dummyimage.com/;
script-src 'self' *.fabrique.social.gouv.fr ${
img-src 'self' blob: data: *.fabrique.social.gouv.fr https://dummyimage.com/;
script-src 'self' *.fabrique.social.gouv.fr 'wasm-unsafe-eval' ${
process.env.NODE_ENV !== "production" && "'unsafe-eval' 'unsafe-inline'"
};
connect-src 'self' *.fabrique.social.gouv.fr ${
connect-src 'self' data: wss: *.fabrique.social.gouv.fr ${
process.env.NODE_ENV !== "production" && "http://localhost:8082"
};
frame-src 'self' *.fabrique.social.gouv.fr;
40 changes: 40 additions & 0 deletions hasura/metadata/databases/default/tables/public_answers.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
table:
name: answers
schema: public
array_relationships:
- name: answers_files
using:
foreign_key_constraint_on:
column: answer_uuid
table:
name: answers_files
schema: public
insert_permissions:
- role: anonymous
permission:
check: {}
columns:
- created_at
- data
- id
- public_key
- sealed_secret
- signature
- submission_bucket_id
select_permissions:
- role: anonymous
permission:
columns:
- id
filter: {}
- role: user
permission:
columns:
- id
- data
- public_key
- sealed_secret
- signature
- submission_bucket_id
- created_at
filter: {}
23 changes: 23 additions & 0 deletions hasura/metadata/databases/default/tables/public_answers_files.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
table:
name: answers_files
schema: public
object_relationships:
- name: answer
using:
foreign_key_constraint_on: answer_uuid
insert_permissions:
- role: anonymous
permission:
check: {}
columns:
- answer_uuid
- file_hash
select_permissions:
- role: user
permission:
columns:
- answer_uuid
- file_hash
- id
filter: {}
allow_aggregations: true
22 changes: 22 additions & 0 deletions hasura/metadata/databases/default/tables/public_files.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
table:
name: files
schema: public
insert_permissions:
- role: anonymous
permission:
check: {}
columns:
- hash
- key
select_permissions:
- role: user
permission:
columns:
- hash
- key
filter: {}
delete_permissions:
- role: user
permission:
backend_only: false
filter: {}
2 changes: 2 additions & 0 deletions hasura/metadata/databases/default/tables/tables.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
- "!include public_answers.yaml"
- "!include public_answers_files.yaml"
- "!include public_books.yaml"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE "public"."answers";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE TABLE "public"."answers" ("id" serial NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "submission_bucket_id" text NOT NULL, "sealed_secret" text NOT NULL, "public_key" text NOT NULL, "data" text NOT NULL, PRIMARY KEY ("id") , UNIQUE ("id"), UNIQUE ("sealed_secret"), UNIQUE ("public_key"));
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."answers" add column "signature" text
-- not null unique;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table "public"."answers" add column "signature" text
not null unique;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE "public"."files";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE TABLE "public"."files" ("hash" text NOT NULL, "key" text NOT NULL, PRIMARY KEY ("hash") );
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE "public"."answers_files";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE TABLE "public"."answers_files" ("id" serial NOT NULL, "answer_id" integer NOT NULL, "file_hash" text NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("answer_id") REFERENCES "public"."answers"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("file_hash") REFERENCES "public"."files"("hash") ON UPDATE cascade ON DELETE cascade, UNIQUE ("answer_id", "file_hash"));
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."files" drop constraint "files_hash_key";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."files" add constraint "files_hash_key" unique ("hash");
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."answers" add column "uuid" uuid
-- not null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table "public"."answers" add column "uuid" uuid
not null;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "public"."answers" ALTER COLUMN "uuid" drop default;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."answers" alter column "uuid" set default gen_random_uuid();
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
alter table "public"."answers_files" add constraint "answers_files_answer_id_file_hash_key" unique (answer_id, file_hash);
alter table "public"."answers_files"
add constraint "answers_files_answer_id_fkey"
foreign key (answer_id)
references "public"."answers"
(id) on update cascade on delete cascade;
alter table "public"."answers_files" alter column "answer_id" drop not null;
alter table "public"."answers_files" add column "answer_id" int4;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."answers_files" drop column "answer_id" cascade;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."answers_files" add column "answer_uuid" uuid
-- not null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table "public"."answers_files" add column "answer_uuid" uuid
not null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
alter table "public"."answers" drop constraint "answers_pkey";
alter table "public"."answers"
add constraint "answers_pkey"
primary key ("id");
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
BEGIN TRANSACTION;
ALTER TABLE "public"."answers" DROP CONSTRAINT "answers_pkey";

ALTER TABLE "public"."answers"
ADD CONSTRAINT "answers_pkey" PRIMARY KEY ("uuid");
COMMIT TRANSACTION;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."answers_files" drop constraint "answers_files_answer_uuid_fkey";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
alter table "public"."answers_files"
add constraint "answers_files_answer_uuid_fkey"
foreign key ("answer_uuid")
references "public"."answers"
("uuid") on update cascade on delete cascade;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."answers_files" drop constraint "answers_files_file_hash_answer_uuid_key";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."answers_files" add constraint "answers_files_file_hash_answer_uuid_key" unique ("file_hash", "answer_uuid");
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."files" add column "answers_files_id" integer
-- null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table "public"."files" add column "answers_files_id" integer
null;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."files" drop constraint "files_answers_files_id_fkey";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
alter table "public"."files"
add constraint "files_answers_files_id_fkey"
foreign key ("answers_files_id")
references "public"."answers_files"
("id") on update cascade on delete cascade;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."answers_files" add column "key" text
-- not null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table "public"."answers_files" add column "key" text
not null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
alter table "public"."files"
add constraint "files_answers_files_id_fkey"
foreign key ("answers_files_id")
references "public"."answers_files"
("id") on update cascade on delete cascade;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."files" drop constraint "files_answers_files_id_fkey";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."files" add constraint "files_hash_key" unique ("hash");
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."files" drop constraint "files_hash_key";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- DROP TABLE files CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE files CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."answers_files" rename column "file_key" to "key";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."answers_files" rename column "key" to "file_key";
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table "public"."answers_files" alter column "file_key" drop not null;
alter table "public"."answers_files" add column "file_key" text;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table "public"."answers_files" drop column "file_key" cascade;
10 changes: 10 additions & 0 deletions next-auth.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import NextAuth, { DefaultSession } from "next-auth";

// augment default Session type
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -36,13 +36,24 @@
"@mui/x-data-grid": "^5.17.16",
"@mui/x-date-pickers": "^5.0.11",
"@sentry/nextjs": "^7.60.1",
"@socialgouv/e2esdk-client": "1.0.0-beta.28",
"@socialgouv/e2esdk-crypto": "1.0.0-beta.20",
"@socialgouv/e2esdk-devtools": "1.0.0-beta.38",
"@socialgouv/e2esdk-react": "1.0.0-beta.28",
"@socialgouv/matomo-next": "^1.6.1",
"boring-avatars": "^1.7.0",
"dayjs": "^1.11.9",
"formidable": "3.2.5",
"js-image-generator": "^1.0.4",
"next": "13.4.12",
"next-auth": "^4.22.3",
"random-words": "^1.3.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"tss-react": "^4.8.8"
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.44.2",
"tss-react": "^4.8.8",
"zod": "^3.21.4"
},
"devDependencies": {
"@babel/core": "^7.22.9",
@@ -55,6 +66,7 @@
"@storybook/testing-library": "^0.0.11",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^14.0.0",
"@types/formidable": "^2.0.6",
"@types/node": "18.11.17",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
17 changes: 17 additions & 0 deletions src/components/devtools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import '@socialgouv/e2esdk-devtools'
import { E2ESDKDevtoolsElement } from '@socialgouv/e2esdk-devtools'
import { useE2ESDKClient } from '@socialgouv/e2esdk-react'
import React from 'react'

export const Devtools = () => {
const client = useE2ESDKClient()
const ref = React.useRef<E2ESDKDevtoolsElement>(null)
React.useEffect(() => {
if (!ref.current) {
return
}
ref.current.client = client
}, [client])
return <e2esdk-devtools ref={ref} theme="dark" />
}

5 changes: 5 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import path from "node:path";

export const storageDir =
process.env.STORAGE_DIR ||
path.resolve(process.cwd(), "./.storage/answers_files");
209 changes: 209 additions & 0 deletions src/lib/e2esdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { z } from "zod";

import {
EncryptedFormLocalState,
base64UrlDecode,
encryptFile,
encryptFormData,
initializeEncryptedFormLocalState,
decryptFileContents,
FileMetadata,
Sodium,
} from "@socialgouv/e2esdk-crypto";

import { Client } from "@socialgouv/e2esdk-client";

import { decryptedDataSchema } from "../../src/pages/form";
import { fetchHasura } from "./hasura";
import { insert_one } from "../queries/form";

const formMetadata = z.object({
id: z.number(),
created_at: z.string(),
public_key: z.string(),
sealed_secret: z.string(),
signature: z.string(),
});

export type EncryptedAnswer = z.infer<typeof formMetadata> & { data: string };

type SubmissionQueryVariables = {
submissionBucketId: string;
signature: string;
sealedSecret: string;
publicKey: string;
answersFiles: any[];
data: string;
};

/**
* Download a file based on its metadata :
* - use the hash to download encrypted blob
* - use the metadata to decipher
* todo: extract to e2esdk ?
*/
export async function downloadAndDecryptFile(
sodium: Sodium,
metadata: FileMetadata
) {
const res = await fetch(`/api/storage?hash=${metadata.hash}`);
const blob = await res.blob();
const cleartext = decryptFileContents(
sodium,
new Uint8Array(await blob.arrayBuffer()),
{
algorithm: "secretBox",
key: sodium.from_base64(metadata.key),
}
);
return new File([cleartext], metadata.name, {
type: metadata.type,
lastModified: metadata.lastModified,
});
}

/**
* Decrypt a given answer
* - decrypt the encrypted values
* - parse and validate with zod
* todo: extract to e2esdk
*/
export const decryptAnswer = (
client: Client,
nameFingerprint: string,
answer: EncryptedAnswer
) => {
try {
const values = client.unsealFormData(
{
metadata: {
publicKey: answer.public_key,
sealedSecret: answer.sealed_secret,
signature: answer.signature,
},
encrypted: { data: answer.data },
},
nameFingerprint
) as Record<"data", string>;

const decryptedValues: FormData = JSON.parse(values.data);
const res = decryptedDataSchema.safeParse(decryptedValues);
if (!res.success) {
console.error(`Zod: Impossible de parser la réponse ${answer.id}`);
console.error(res.error);
// warning : returning null here is recommended to avoid security issues where malicious content is sent that could break the rendering process, and lead to a blank page.
// return null;

return {
...answer,
...decryptedValues,
};
}
return {
...answer,
...res.data,
data: undefined,
};
} catch (e) {
console.error(`e2esdk: Impossible de parser la réponse ${answer.id}`);
console.error(e);
return null;
}
};

/**
* return encrypted version and metadata for a given `File`
* todo: extract to e2esdk
*/
export const readAndEncryptFile = async (
file: File,
encryptionState: EncryptedFormLocalState
): Promise<ReturnType<typeof encryptFile>> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onabort = () => reject("file reading was aborted");
reader.onerror = () => reject("file reading has failed");
reader.onload = async () => {
const binaryStr = reader.result;
if (binaryStr) {
const { encryptedFile, metadata } = await encryptFile(
encryptionState.sodium,
file
);
resolve({ encryptedFile, metadata });
}
};
reader.readAsArrayBuffer(file);
});

/**
* Encrypt input data using e2esdk and submit to the server
* - create a dedicated FormLocalState
* - encrypt files using e2esdk readAndEncryptFile
* - upload encrypted files and keep track of files in submitted data
* - submit the whole encrypted form data, with files references
*/
export const encryptAndSubmitForm = async (
formPublicKeyString: string,
formName: string,
data: Record<string, any>
) => {
const formPublicKey = base64UrlDecode(formPublicKeyString);

// initialize form encryption context
const state = await initializeEncryptedFormLocalState(
formName,
formPublicKey
);

// first encrypt files if any
const answersFiles = [];
if (data.files) {
const { files } = data;
delete data.files;
const encryptedFiles = await Promise.all(
files.map((file: File) => readAndEncryptFile(file, state))
);
const formData = new FormData();
for (let i = 0; i < encryptedFiles.length; i++) {
const { encryptedFile, metadata } = encryptedFiles[i];
formData.set(`file_${i}`, encryptedFile);
}
// add `filesMetadata` to our data object, it will be encrypted too
data.filesMetadata = {};
// upload files and save metadata
if (Array.from(formData.values()).flat().length > 0) {
await fetch("/api/upload-answers-files", {
method: "POST",
body: formData,
});
for (const { metadata } of encryptedFiles) {
const { hash } = metadata;
// the file_hash is kept as a reference in cleartext in the application database
answersFiles.push({ file_hash: hash });
data.filesMetadata[hash] = metadata;
}
}
}

// encrypt our `data` object
const { metadata, encrypted } = encryptFormData(
{ data: JSON.stringify(data) },
state
);

// prepare hasura graphql query variables
const variables: SubmissionQueryVariables = {
submissionBucketId: formName,
sealedSecret: metadata.sealedSecret,
signature: metadata.signature,
publicKey: metadata.publicKey,
answersFiles,
...encrypted,
};

return fetchHasura({
query: insert_one,
variables,
});
};
4 changes: 2 additions & 2 deletions src/lib/hasura.ts
Original file line number Diff line number Diff line change
@@ -23,7 +23,7 @@ export type Token = {

export const fetchHasura = (
params: HasuraParams,
token: Token,
token?: Token,
retry: number = 5
): Promise<HasuraJsonResponse> => {
const checkExpiredToken = async (res: HasuraJsonResponse) => {
@@ -58,7 +58,7 @@ export const fetchHasura = (
body: JSON.stringify(params),
headers: {
"Content-Type": "application/json",
...(token.accessToken
...(token?.accessToken
? { Authorization: `Bearer ${token.accessToken}` }
: {}), // allow anonymous users with no Authorization header
},
18 changes: 18 additions & 0 deletions src/lib/serialExec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const wait =
(timeout = 500) =>
(args: any) =>
new Promise((resolve) => setTimeout(() => resolve(args), timeout));

export const serialExec = (promises: any[], options = { randomTimeout: 0 }) => {
const next = (res = null) =>
options.randomTimeout ? wait(options.randomTimeout) : Promise.resolve(res);
return promises.reduce(
(chain, c) =>
chain.then((res: any) =>
c()
.then(next())
.then((cur: any) => [...res, cur])
),
Promise.resolve([])
);
};
46 changes: 42 additions & 4 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { SessionProvider, signIn, useSession } from "next-auth/react";
import dynamic from "next/dynamic";

import { createEmotionSsrAdvancedApproach } from "tss-react/next/pagesDir";
import { createNextDsfrIntegrationApi } from "@codegouvfr/react-dsfr/next-pagesdir";
@@ -15,12 +16,28 @@ import { MuiDsfrThemeProvider } from "@codegouvfr/react-dsfr/mui";
import { init } from "@socialgouv/matomo-next";
import { useStyles } from "tss-react/dsfr";

import { Client } from "@socialgouv/e2esdk-client";
import { E2ESDKClientProvider } from "@socialgouv/e2esdk-react";

declare module "@codegouvfr/react-dsfr/next-pagesdir" {
interface RegisterLink {
Link: typeof Link;
}
}

const e2esdkClient = new Client({
serverURL: "https://e2esdk.dev.fabrique.social.gouv.fr",
serverSignaturePublicKey: "_XDQj6-paJAnpCp_pfBhGUUe6cA0MjLXsgAOgYDhCRI",
});

const Devtools = dynamic(
() => import("../components/devtools").then((m) => m.Devtools),
{
ssr: false,
}
);

// Only in TypeScript projects
declare module "@codegouvfr/react-dsfr" {
interface RegisterLink {
Link: typeof Link;
@@ -156,6 +173,24 @@ const Layout = ({ children }: { children: ReactNode }) => {
},
isActive: router.asPath === "/books",
},
{
menuLinks: [
{
text: "Form",
linkProps: {
href: "/form",
},
},
{
text: "Answers",
linkProps: {
href: "/answers",
},
},
],
isActive: ["/form", "/answers"].includes(router.asPath),
text: "E2ESDK forms",
},
];
return (
<MuiDsfrThemeProvider>
@@ -184,7 +219,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<div
className={css({
margin: "auto",
maxWidth: 1000,
maxWidth: 1200,
...fr.spacing("padding", {
topBottom: "10v",
}),
@@ -219,9 +254,12 @@ function App({ Component, pageProps }: AppProps) {
}, []);
return (
<SessionProvider session={pageProps.session}>
<Layout>
<Component {...pageProps} />
</Layout>
<E2ESDKClientProvider client={e2esdkClient}>
<Layout>
<Component {...pageProps} />
</Layout>
<Devtools />
</E2ESDKClientProvider>
</SessionProvider>
);
}
257 changes: 257 additions & 0 deletions src/pages/answers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/* eslint-disable @next/next/no-img-element */
import { useCallback, useEffect, useMemo, useState } from "react";
import type { NextPage } from "next";
import { useSession } from "next-auth/react";

import { fr } from "@codegouvfr/react-dsfr";
import { Alert } from "@codegouvfr/react-dsfr/Alert";
import { createModal } from "@codegouvfr/react-dsfr/Modal";
import { useIsModalOpen } from "@codegouvfr/react-dsfr/Modal/useIsModalOpen";

import { DataGrid, GridColDef } from "@mui/x-data-grid";

import {
useE2ESDKClient,
useE2ESDKClientIdentity,
} from "@socialgouv/e2esdk-react";
import { FileMetadata } from "@socialgouv/e2esdk-crypto";

import { fetchHasura } from "../lib/hasura";
import { query } from "../queries/form";
import {
decryptAnswer,
downloadAndDecryptFile,
EncryptedAnswer,
} from "../lib/e2esdk";

type AnswersResponse = {
errors?: { message: string }[];
data: {
answers: EncryptedAnswer[];
};
};

// nameFingerprint of the form sealedBox
const formNameFingerprint = "QyUHkrtl2FmtRyXHbsmf_EIwKGv5wRUZpU9X7Gm4tZw";

const modal = createModal({
id: "preview",
isOpenedByDefault: false,
});

const EncryptedImagePreview = ({ file }: { file: File }) => {
return (
<img
style={{ maxWidth: "100%" }}
src={URL.createObjectURL(file)}
title={file.name}
alt={file.name}
/>
);
};

// to download the file
// function saveFile(file: File) {
// const link = document.createElement("a");
// link.setAttribute("href", URL.createObjectURL(file));
// link.setAttribute("download", file.name);
// link.click();
// URL.revokeObjectURL(link.href);
// }

const Answers: NextPage = () => {
const client = useE2ESDKClient();

const isOpen = useIsModalOpen(modal);

const onFileClick = useCallback(
(metadata: FileMetadata) => {
setPreviewImage(null);
downloadAndDecryptFile(client.sodium, metadata).then((rawImage) => {
setPreviewImage(rawImage); // or saveFile(rawImage)
// ensure state is updated before showing the modal
setTimeout(() => {
if (!isOpen) {
modal.open();
}
});
});
},
[client, isOpen]
);

const columns: GridColDef[] = useMemo(
() => [
{ field: "id", headerName: "ID", width: 70 },

{
field: "created_at",
headerName: "Date",
width: 130,
type: "date",
valueGetter: (val) => new Date(val.row.created_at),
},
{
field: "firstName",
type: "string",
headerName: "Prénom",
width: 120,
},
{
field: "lastName",
type: "string",
headerName: "Nom",
width: 120,
},
{
field: "email",
headerName: "Email",
type: "email",
width: 150,
renderCell: (cell) => (
<a href={`mailto:${cell.row.email}`}>{cell.row.email}</a>
),
},
{
field: "newsletter",
headerName: "Emails",
width: 70,
align: "center",
valueGetter: (val) => (val.row.newsletter && "✅") || "❌",
},
{
field: "alerts",
headerName: "Alertes",
width: 70,
align: "center",
valueGetter: (val) => (val.row.alerts && "✅") || "❌",
},
{
field: "message",
headerName: "Message",
type: "text",
flex: 1,
},
{
field: "filesData",
headerName: "Fichiers",
type: "text",
flex: 1,
width: 70,
cellClassName: "no-outline",
renderCell: (cell) => {
const fileList: FileMetadata[] = Object.values(
cell.row.filesMetadata || {}
);
return fileList.map((metadata, i) => (
<span
key={metadata.hash + i}
title={metadata.name}
className={fr.cx("fr-icon-file-download-line")}
style={{ cursor: "pointer" }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFileClick(metadata);
}}
aria-hidden={true}
></span>
));
},
},
],
[onFileClick]
);

const { data: session } = useSession();
const token = useMemo(
() => ({
//@ts-ignore
accessToken: session?.accessToken || "",
//@ts-ignore
refreshToken: session?.refreshToken || "",
//@ts-ignore
accessTokenExpires: session?.accessTokenExpires || 0,
}),
[session]
);
const [answers, setAnswers] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const [previewImage, setPreviewImage] = useState<File | null>(null);

const identity = useE2ESDKClientIdentity();

const fetchAnswers = useCallback(
() =>
fetchHasura({ query }, token).then((res: AnswersResponse) =>
res.data.answers
.map((answer) => decryptAnswer(client, formNameFingerprint, answer))
.filter(Boolean)
),
[client, token]
);

useEffect(() => {
setError(null);
if (session?.user?.id) {
// autologin / signup to e2esdk using user UUID
const e2esdkUserId = session.user.id;
// if alreay logged in e2esdk in with application account id
if (identity && identity.userId === e2esdkUserId) {
console.log("fetchAnswers");
fetchAnswers().then(setAnswers);
} else {
// log the user to e2esdk
console.log("client.login(e2esdkUserId)", e2esdkUserId);
client.login(e2esdkUserId).catch(async (e) => {
console.log(e);
// if not logged in, force new application register in e2esdk
if (e.message.match("Device is not enrolled for this user")) {
console.log("client.logout()");
await client.logout();
console.log("client.signup(e2esdkUserId)");
await client.signup(e2esdkUserId);
return;
}
throw e;
});
}
}
}, [fetchAnswers, identity, client, session]);

return (
<>
{error && (
<Alert
style={{ marginBottom: 20 }}
severity="error"
small
description={error}
/>
)}
<div style={{ height: 800 }}>
<modal.Component title="Preview">
{previewImage && <EncryptedImagePreview file={previewImage} />}
</modal.Component>
<DataGrid
rows={answers}
columns={columns}
sx={{
"& *": {
outline: "none !important",
},
}}
initialState={{
sorting: {
sortModel: [{ field: "created_at", sort: "desc" }],
},
}}
autoPageSize={true}
checkboxSelection
/>
</div>
</>
);
};

export default Answers;
32 changes: 32 additions & 0 deletions src/pages/api/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// https://chadalen.com/blog/how-to-use-a-multipart-form-in-nextjs-using-api-routes

import { fileMetadataSchema } from "@socialgouv/e2esdk-crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";

import { storageDir } from "../../config";

const validFileName = fileMetadataSchema.shape.hash;

export default async function storageEndpoint(
req: NextApiRequest,
res: NextApiResponse
) {
// todo: Add authentication

const hash = validFileName.parse([req.query["hash"]].flat()[0]);
if (!hash) {
return res.status(400).send("Expected hash query string");
}
const filePath = path.resolve(storageDir, hash);
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return res.status(404).send("Not found");
}
return res
.setHeader("content-type", "application/octet-stream")
.setHeader("content-disposition", "inline")
.send(createReadStream(filePath));
}
76 changes: 76 additions & 0 deletions src/pages/api/upload-answers-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// https://chadalen.com/blog/how-to-use-a-multipart-form-in-nextjs-using-api-routes

import { fileMetadataSchema } from "@socialgouv/e2esdk-crypto";
import formidable from "formidable";
import type { NextApiRequest, NextApiResponse } from "next";
import fs from "node:fs/promises";
import path from "node:path";

import { storageDir } from "../../config";

export const config = {
api: {
bodyParser: false,
},
};

// SHA-512 hex output
const validFileName = fileMetadataSchema.shape.hash;

const form = formidable({
multiples: true,
maxFileSize: 10 * 1024 * 1024, // 10Mb
uploadDir: storageDir,
hashAlgorithm: "sha512",
filename(name, ext, part, form) {
if (!part.originalFilename) {
throw new Error("Missing file name (should be hash of content)");
}
return validFileName.parse(part.originalFilename);
},
});

form.on("file", (_formName, file) => {
if (!file.hash) {
console.error(`Skip file ${file.filepath}, no hash`);
return;
}
if (file.newFilename === file.hash) {
console.info(`Saved file ${file.filepath}`);
return;
}
console.error(`Invalid file name (does not match SHA-512 hash)
Received: ${file.newFilename}
Hashed: ${file.hash}`);
fs.rm(path.resolve(storageDir, file.newFilename));
});

export default async function storageEndpoint(
req: NextApiRequest,
res: NextApiResponse
) {
// todo: Add authentication
await fs.mkdir(storageDir, { recursive: true }).catch(() => {});
if (
!req.headers["content-type"] ||
req.headers["content-type"].indexOf("multipart/form-data") === -1
) {
return res
.status(415)
.send("Invalid content-type, only multipart/form-data is accepted");
}
await new Promise<void>((resolve, reject) =>
form.parse(req, (err, _, files) => {
if (err) {
res.status(400).json({
error: "Invalid request",
message: "Failed to parse multipart body",
reason: err,
});
reject(err);
}
resolve();
})
);
res.status(201).send(null);
}
285 changes: 285 additions & 0 deletions src/pages/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import React, { useState, useCallback } from "react";
import type { NextPage } from "next";
import Head from "next/head";
import { useDropzone } from "react-dropzone";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { fr } from "@codegouvfr/react-dsfr";
import { useIsDark } from "@codegouvfr/react-dsfr/useIsDark";
import { Input } from "@codegouvfr/react-dsfr/Input";
import { RadioButtons } from "@codegouvfr/react-dsfr/RadioButtons";
import { Checkbox } from "@codegouvfr/react-dsfr/Checkbox";
import { Button } from "@codegouvfr/react-dsfr/Button";
import { Alert } from "@codegouvfr/react-dsfr/Alert";

import { fileMetadataSchema } from "@socialgouv/e2esdk-crypto";

import { encryptAndSubmitForm } from "../lib/e2esdk";
import { generateFormData } from "../services/fake-form-data";
import { serialExec } from "../lib/serialExec";

// This form sealedBox public key
const formPublicKeyString = "CyLwBWHufxjHfzzI8BNgJXEBOEtIXV_NewC6VFrMkgk";

// Some unique id for this form submission. used for client localstorage
export const formName = "myapp-contact-form";

export const decryptedDataSchema = z.object({
firstName: z.string(),
lastName: z.string().optional().nullable(),
message: z.string().optional().nullable(),
email: z.string().optional().nullable(),
color: z.string().optional().nullable(),
newsletter: z.boolean().optional().default(false),
alerts: z.boolean().optional().default(false),
files: z.array(z.any()).optional(),
filesMetadata: fileMetadataSchema,
});

type FormData = z.infer<typeof decryptedDataSchema>;

const removeFromArray = (arr: any[], value: any) => {
const index = arr.indexOf(value);
if (index > -1) {
return [...arr.slice(0, index), ...arr.slice(index + 1)];
}
return arr;
};

const Form: NextPage = () => {
const {
register,
handleSubmit,
reset,
formState: { isDirty, isValid },
} = useForm<FormData>({ mode: "onChange" });
const { isDark } = useIsDark();
const [formSuccess, setFormSuccess] = useState<boolean | null>(null);
const [formError, setFormError] = useState<boolean | null>(null);
const [uploads, setUploads] = useState<File[]>([]);

const generateSubmissions = useCallback(async () => {
// generate and submit bunch of fake submissions
const rows = await Promise.all(
Array.from({ length: 10 }, generateFormData)
);
console.time("encryptAndSubmitForm");
return serialExec(
rows.map(
(row) => () => encryptAndSubmitForm(formPublicKeyString, formName, row)
)
).then(() => {
console.timeEnd("encryptAndSubmitForm");
setFormError(false);
setFormSuccess(true);
setUploads([]);
reset();
});
}, [reset]);

const onDrop = (acceptedFiles: File[]) => {
setUploads([...uploads, ...acceptedFiles]);
};

const onRemoveUploadClick = (upload: File) => {
const handler: React.MouseEventHandler<HTMLSpanElement> = (e) => {
e.preventDefault();
e.stopPropagation();
const newUploads = removeFromArray(uploads, upload);
setUploads(newUploads);
};
return handler;
};

const {
getRootProps: getDropZoneRootProps,
getInputProps: getDropZoneInputProps,
isDragActive,
} = useDropzone({
onDrop,
accept: {
"image/png": [".png"],
"image/jpg": [".jpg", ".jpeg"],
"image/gif": [".gif"],
},
});

const onSubmit = handleSubmit(async (data) => {
console.log("handleSubmit", data);
setFormError(null);
setFormSuccess(null);

data.files = uploads; // use local state for uploads

encryptAndSubmitForm(formPublicKeyString, formName, data)
.then((res) => {
if (res?.data?.insert_answers_one?.id) {
setFormError(false);
setFormSuccess(true);
setUploads([]);
reset();
}
})
.catch((e) => {
console.error(e);
setFormError(true);
setFormSuccess(false);
});
});

return (
<>
<Head>
<title>e2esdk demo - SocialGouv</title>/
</Head>
<div className={fr.cx("fr-container")}>
<Alert
description="Ce formulaire utilise du chiffrement côté client avant d'envoyer les données."
severity="info"
small
/>
{formSuccess && (
<Alert
description="Données chiffrées et envoyées"
severity="success"
small
/>
)}
{formError && (
<Alert
description="Impossible d'envoyer les données"
severity="error"
small
/>
)}
<br />
<form onSubmit={onSubmit}>
<Input
label="Nom"
nativeInputProps={{ ...register("firstName", { required: true }) }}
/>
<Input label="Prénom(s)" nativeInputProps={register("lastName")} />
<Input
label="Email"
nativeInputProps={{
type: "email",
...register("email"),
}}
/>
<RadioButtons
legend="Thème de couleur préféré"
options={[
{
label: "Quiet Light",
nativeInputProps: {
value: "Quiet Light",
...register("color"),
},
},
{
label: "Monokai",
nativeInputProps: {
value: "Monokai",
...register("color"),
},
},
{
label: "Solarized",
nativeInputProps: {
value: "Solarized",
...register("color"),
},
},
{
label: "DSFR",
nativeInputProps: {
value: "DSFR",
...register("color"),
},
},
]}
orientation="horizontal"
/>
<Checkbox
legend="Communications"
options={[
{
label: "Recevoir la newsletter",
nativeInputProps: {
...register("newsletter"),
},
},
{
label: "Recevoir les alertes",
nativeInputProps: {
...register("alerts"),
},
},
]}
/>
<Input
label="Message"
textArea={true}
nativeTextAreaProps={{
...register("message"),
rows: 6,
}}
/>
<div {...getDropZoneRootProps()}>
<Input
label="Vos fichiers"
nativeInputProps={{ ...getDropZoneInputProps() }}
{...register("files")}
/>
<div
style={{
border: "3px dashed auto",
padding: 10,
listStyleType: "none",
marginTop: 10,
minHeight: 140,
borderColor:
fr.getColors(isDark)?.decisions.background.alt.grey.active,
backgroundColor: isDragActive
? fr.getColors(isDark)?.decisions.background.alt.grey.active
: fr.getColors(isDark)?.decisions.background.contrast.grey
.default,
}}
>
Déposez vos fichiers ici...
{(uploads.length && (
<div style={{ marginTop: 10 }}>
{uploads.map((upload, i) => (
<li key={upload.name + i}>
{upload.name}{" "}
{/* eslint-disable-next-line react/jsx-no-comment-textnodes, jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<span
style={{ cursor: "pointer" }}
onClick={onRemoveUploadClick(upload)}
>
X
</span>
</li>
))}
</div>
)) ||
null}
</div>
</div>
<br />
<Button onClick={onSubmit} disabled={!isDirty || !isValid}>
Envoyer
</Button>
<br />
<br />
<Button onClick={generateSubmissions}>
Send 10 random submissions
</Button>
</form>
</div>
</>
);
};

export default Form;
57 changes: 45 additions & 12 deletions src/pages/profil.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,42 @@
import React from "react";
import React, { useCallback } from "react";
import { withAuth } from "../lib/auth";
import { useSession, signOut } from "next-auth/react";
import { Button } from "@codegouvfr/react-dsfr/Button";
import { ButtonsGroup } from "@codegouvfr/react-dsfr/ButtonsGroup";
import { useE2ESDKClient } from "@socialgouv/e2esdk-react";
import { fr } from "@codegouvfr/react-dsfr";

const ProfilPage = () => {
const client = useE2ESDKClient();
const { data: session } = useSession();

const backupKey = useCallback(async () => {
const deviceQR = await client.enrollNewDevice();
const textFile = new File([deviceQR], "key-backup.txt", {
type: "text",
});
const link = document.createElement("a");
link.setAttribute("href", URL.createObjectURL(textFile));
link.setAttribute("download", textFile.name);
link.click();
URL.revokeObjectURL(link.href);
}, [client]);

const logout = useCallback(async () => {
signOut({
// @ts-ignore // todo
callbackUrl: `/api/logout?id_token_hint=${session?.idToken}`,
});
}, [session]);

return (
<div className="fr-container fr-my-6w">
<h1>Mes informations</h1>
<div className="fr-px-3w">
<ul>
<li>
<span className="fr-text--bold">Id : </span>
<span>{session?.user?.id ?? ""}</span>
</li>
<li>
<span className="fr-text--bold">Email : </span>
<span>{session?.user?.email ?? ""}</span>
@@ -19,16 +46,22 @@ const ProfilPage = () => {
<span>{session?.user?.name}</span>
</li>
</ul>
<Button
onClick={() =>
signOut({
// @ts-ignore
callbackUrl: `/api/logout?id_token_hint=${session?.idToken}`,
})
}
>
Se déconnecter
</Button>
<ButtonsGroup
className={fr.cx("fr-mt-12w")}
buttons={[
{
iconId: "ri-key-2-fill",
onClick: backupKey,
children: "Créer une clé de sauvegarde",
},
{
priority: "secondary",
iconId: "ri-door-closed-fill",
onClick: logout,
children: "Se déconnecter",
},
]}
/>
</div>
</div>
);
37 changes: 37 additions & 0 deletions src/queries/form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export const insert_one = `
mutation MyMutation(
$data: String,
$publicKey: String,
$sealedSecret: String,
$signature: String,
$submissionBucketId: String,
$answersFiles: [answers_files_insert_input!]!
) {
insert_answers_one(object: {
data: $data,
public_key: $publicKey,
sealed_secret: $sealedSecret,
signature: $signature,
submission_bucket_id: $submissionBucketId
answers_files: {
data: $answersFiles
}
}) {
id
}
}
`;

export const query = `
query MyQuery {
answers {
created_at
data
id
public_key
sealed_secret
signature
submission_bucket_id
}
}
`;
137 changes: 137 additions & 0 deletions src/services/fake-form-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import randomWords from "random-words";
import imgGen from "js-image-generator";

const frenchFirstNames = [
"Emma",
"Lucas",
"Chloé",
"Louis",
"Léa",
"Hugo",
"Jade",
"Adam",
"Lina",
"Gabriel",
"Inès",
"Raphaël",
"Manon",
"Arthur",
"Louise",
"Noah",
"Alice",
"Théo",
"Rose",
"Ethan",
"Juliette",
"Gabin",
"Anna",
"Timéo",
"Clara",
"Eliott",
"Alix",
];

const frenchLastNames = [
"Dupont",
"Dubois",
"Martin",
"Bernard",
"Thomas",
"Petit",
"Robert",
"Richard",
"Durand",
"Moreau",
"Simon",
"Laurent",
"Lefebvre",
"Michel",
"Girard",
"Roux",
"Vincent",
"Fournier",
"Morel",
"Garnier",
"Barbier",
"Perrin",
"Gauthier",
"Dumont",
"Moulin",
"Gonzalez",
"Bertrand",
"Renaud",
"Fontaine",
"Caron",
"Faure",
"Mercier",
"Blanc",
"Legrand",
"Guillaume",
];

const universeQuotes = [
"La vie est une chance, saisis-la. La vie est beauté, admire-la. La vie est béatitude, savoure-la. La vie est un rêve, fais-en une réalité.",
"La science de l'univers, c'est d'abord l'amour de l'univers.",
"L'univers est une symphonie harmonieuse où chaque élément a sa place et son rôle.",
"Nous sommes tous des poussières d'étoiles.",
"L'univers est plein de magie et il attend patiemment que notre intelligence s'affine.",
"La science n'a pas de patrie, parce que la connaissance est le patrimoine de l'humanité, l'émancipation de l'homme de toute contrainte et de toute oppression.",
"Il y a des moments où les étoiles, comme les gens, doivent se mettre à nu pour révéler leur vrai caractère.",
"Les étoiles sont les yeux du ciel.",
"L'univers est un livre dont on n'a lu que la première page quand on n'a vu que son propre pays.",
"Nous sommes des êtres éphémères, mais notre univers est éternel.",
];

const funThemeColors = [
"Bubblegum Pink",
"Lemon Yellow",
"Mint Green",
"Sky Blue",
"Lavender Purple",
"Tangerine Orange",
"Watermelon Red",
"Ocean Blue",
"Sunny Orange",
"Cotton Candy Blue",
];

const slufigy = (str: string) => str.toLowerCase().replace(/[^\w]/g, "-");

const pick = (arr: any[]) => arr[Math.floor(Math.random() * arr.length)];

const generateImage = async (): Promise<Buffer> =>
new Promise((resolve, reject) => {
imgGen.generateImage(800, 600, 80, function (err, image) {
if (err) {
return reject(err);
}
resolve(image.data);
});
});

export const generateFormData = async () => {
const firstName = pick(frenchFirstNames);
const lastName = pick(frenchLastNames);

const files = [];
for (let i = 0; i < Math.floor(Math.random() * 5); i++) {
const [word] = randomWords(1);
const image = await generateImage();
const file = new File([image], `${word}.jpg`, {
type: "image/jpeg",
lastModified: Date.now(),
});
files.push(file);
}

return {
firstName,
lastName,
message: pick(universeQuotes),
email: `${slufigy(firstName)}.${slufigy(lastName)}@mel.com`,
color: pick(funThemeColors),
newsletter: pick([true, false]),
alerts: pick([true, false]),
files,
};
};
1,502 changes: 1,449 additions & 53 deletions yarn.lock

Large diffs are not rendered by default.