- Remplacement de DocuSeal par solution souveraine Odentas Sign - Système d'authentification OTP pour signataires (bcryptjs + JWT) - 8 routes API: send-otp, verify-otp, sign, pdf-url, positions, status, webhook, signers - Interface moderne avec canvas de signature et animations (framer-motion, confetti) - Système de templates pour auto-détection des positions de signature (CDDU, RG, avenants) - PDF viewer avec @react-pdf-viewer (compatible Next.js) - Stockage S3: source/, signatures/, evidence/, signed/, certs/ - Tables Supabase: sign_requests, signers, sign_positions, sign_events, sign_assets - Evidence bundle automatique (JSON metadata + timestamps) - Templates emails: OTP et completion - Scripts Lambda prêts: pades-sign (KMS seal) et tsaStamp (RFC3161) - Mode test détecté automatiquement (emails whitelist) - Tests complets avec PDF CDDU réel (2 signataires)
187 lines
4.7 KiB
TypeScript
187 lines
4.7 KiB
TypeScript
import { S3Client, PutObjectCommand, GetObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
|
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
import { calculateSHA256, sanitizeFilename } from './crypto';
|
|
|
|
const region = process.env.AWS_REGION || 'eu-west-3';
|
|
const s3Client = new S3Client({ region });
|
|
|
|
const BUCKET = process.env.ODENTAS_SIGN_BUCKET || 'odentas-sign';
|
|
|
|
// Préfixes des dossiers dans le bucket
|
|
export const S3_PREFIXES = {
|
|
SOURCE: 'source/',
|
|
SIGNED: 'signed/',
|
|
EVIDENCE: 'evidence/',
|
|
SIGNATURES: 'signatures/',
|
|
CERTS: 'certs/',
|
|
} as const;
|
|
|
|
/**
|
|
* Upload un fichier vers S3
|
|
*/
|
|
export async function uploadToS3(params: {
|
|
key: string;
|
|
body: Buffer | string;
|
|
contentType?: string;
|
|
metadata?: Record<string, string>;
|
|
}): Promise<{ key: string; sha256: string }> {
|
|
const { key, body, contentType = 'application/octet-stream', metadata = {} } = params;
|
|
|
|
const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
const sha256 = calculateSHA256(bodyBuffer);
|
|
|
|
await s3Client.send(new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
Body: bodyBuffer,
|
|
ContentType: contentType,
|
|
Metadata: {
|
|
...metadata,
|
|
sha256,
|
|
uploaded_at: new Date().toISOString(),
|
|
},
|
|
}));
|
|
|
|
console.log(`[S3] ✅ Uploaded: ${key} (${bodyBuffer.length} bytes, SHA256: ${sha256})`);
|
|
|
|
return { key, sha256 };
|
|
}
|
|
|
|
/**
|
|
* Télécharge un fichier depuis S3
|
|
*/
|
|
export async function downloadFromS3(key: string): Promise<Buffer> {
|
|
const response = await s3Client.send(new GetObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
}));
|
|
|
|
if (!response.Body) {
|
|
throw new Error(`Fichier introuvable: ${key}`);
|
|
}
|
|
|
|
const chunks: Uint8Array[] = [];
|
|
for await (const chunk of response.Body as any) {
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
/**
|
|
* Génère une URL pré-signée pour télécharger un fichier
|
|
*/
|
|
export async function getPresignedDownloadUrl(key: string, expiresIn: number = 3600): Promise<string> {
|
|
const command = new GetObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
});
|
|
|
|
return await getSignedUrl(s3Client, command, { expiresIn });
|
|
}
|
|
|
|
/**
|
|
* Upload une image de signature
|
|
*/
|
|
export async function uploadSignatureImage(params: {
|
|
requestId: string;
|
|
signerId: string;
|
|
imageBase64: string;
|
|
}): Promise<string> {
|
|
const { requestId, signerId, imageBase64 } = params;
|
|
|
|
// Extraire le type MIME et les données
|
|
const matches = imageBase64.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
|
|
if (!matches || matches.length !== 3) {
|
|
throw new Error('Format image base64 invalide');
|
|
}
|
|
|
|
const mimeType = matches[1];
|
|
const data = matches[2];
|
|
const buffer = Buffer.from(data, 'base64');
|
|
|
|
// Déterminer l'extension
|
|
const extension = mimeType === 'image/png' ? 'png' : 'jpg';
|
|
|
|
// Clé S3
|
|
const key = `${S3_PREFIXES.SIGNATURES}${requestId}/${signerId}.${extension}`;
|
|
|
|
await uploadToS3({
|
|
key,
|
|
body: buffer,
|
|
contentType: mimeType,
|
|
metadata: {
|
|
request_id: requestId,
|
|
signer_id: signerId,
|
|
},
|
|
});
|
|
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Upload le bundle de preuves (evidence)
|
|
*/
|
|
export async function uploadEvidenceBundle(params: {
|
|
requestRef: string;
|
|
evidence: any;
|
|
}): Promise<string> {
|
|
const { requestRef, evidence } = params;
|
|
|
|
const key = `${S3_PREFIXES.EVIDENCE}${sanitizeFilename(requestRef)}.json`;
|
|
|
|
await uploadToS3({
|
|
key,
|
|
body: JSON.stringify(evidence, null, 2),
|
|
contentType: 'application/json',
|
|
metadata: {
|
|
request_ref: requestRef,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Copie un fichier vers le dossier d'archivage avec Object Lock
|
|
*/
|
|
export async function copyToArchive(params: {
|
|
sourceKey: string;
|
|
destinationKey: string;
|
|
retainUntilDate: Date;
|
|
}): Promise<void> {
|
|
const { sourceKey, destinationKey, retainUntilDate } = params;
|
|
|
|
await s3Client.send(new CopyObjectCommand({
|
|
Bucket: BUCKET,
|
|
CopySource: `${BUCKET}/${sourceKey}`,
|
|
Key: destinationKey,
|
|
ObjectLockMode: 'COMPLIANCE',
|
|
ObjectLockRetainUntilDate: retainUntilDate,
|
|
Metadata: {
|
|
archived_at: new Date().toISOString(),
|
|
retain_until: retainUntilDate.toISOString(),
|
|
},
|
|
}));
|
|
|
|
console.log(`[S3] 🔒 Archived with Object Lock: ${destinationKey} (retain until ${retainUntilDate.toISOString()})`);
|
|
}
|
|
|
|
/**
|
|
* Vérifie si un fichier existe dans S3
|
|
*/
|
|
export async function fileExistsInS3(key: string): Promise<boolean> {
|
|
try {
|
|
await s3Client.send(new GetObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
}));
|
|
return true;
|
|
} catch (error: any) {
|
|
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|