espace-paie-odentas/lib/odentas-sign/s3.ts
odentas b790faf12c feat: Implémentation complète du système Odentas Sign
- 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)
2025-10-27 19:03:07 +01:00

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