// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Carte de membre Groupe KA pour Apple Wallet (.pkpass). // Assemble pass.json + icônes, calcule le manifest (SHA-1), signe en PKCS#7 // détaché via openssl (certificat Pass Type ID + WWDR), zippe et sert. // Config .env.local du nœud : // KA_PASS_DIR dossier contenant pass-cert.pem, pass-key.pem, wwdr.pem // KA_PASS_TYPE_ID ex. pass.com.groupe-ka.membre // KA_PASS_TEAM_ID ex. 3YM54G49SN import { NextResponse } from "next/server"; import { execFile } from "child_process"; import { promisify } from "util"; import crypto from "crypto"; import fs from "fs/promises"; import os from "os"; import path from "path"; import sharp from "sharp"; import { getSessionUser, BASE_URL } from "@/lib/auth"; import { ensureKaId } from "@/lib/db"; import { roleCard } from "@/lib/roles"; const run = promisify(execFile); function fmtDate(iso: string | null): string { if (!iso) return ""; const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z"); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric", }); } export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.redirect( new URL("/connexion?next=%2Fcompte", BASE_URL), ); const certDir = process.env.KA_PASS_DIR; const passTypeId = process.env.KA_PASS_TYPE_ID; const teamId = process.env.KA_PASS_TEAM_ID; if (!certDir || !passTypeId || !teamId) return NextResponse.json( { error: "Apple Wallet pas encore activé — certificat Pass Type ID en attente (KA_PASS_DIR / KA_PASS_TYPE_ID / KA_PASS_TEAM_ID).", }, { status: 503 }, ); const kaId = user.kaId ?? ensureKaId(user.id); const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "kapass-")); try { // 1. pass.json — carte générique aux couleurs du groupe const pass = { formatVersion: 1, passTypeIdentifier: passTypeId, teamIdentifier: teamId, serialNumber: kaId, organizationName: "Groupe KA", description: "Carte de membre Groupe KA", logoText: "", foregroundColor: "rgb(245,243,238)", backgroundColor: "rgb(20,24,20)", labelColor: "rgb(217,242,107)", generic: { primaryFields: [{ key: "kaid", label: "KA-ID", value: kaId }], secondaryFields: [ { key: "membre", label: "MEMBRE", value: user.name || user.email }, ...(roleCard(user.role) ? [{ key: "statut", label: "STATUT", value: roleCard(user.role)! }] : []), ], auxiliaryFields: [ ...(fmtDate(user.createdAt) ? [ { key: "depuis", label: "MEMBRE DEPUIS", value: fmtDate(user.createdAt), }, ] : []), { key: "portee", label: "VALIDE SUR", value: "Les 7 plateformes ·Ka", }, ], backFields: [ { key: "verif", label: "Vérification", value: `${BASE_URL}/m/${kaId}`, }, { key: "plateformes", label: "Plateformes", value: "Lou·Ka · Immo·Ka · Vrai-Prix · ValoPlex · Auto·Ka · Fabri·Ka · Food·Ka", }, { key: "contact", label: "Contact", value: "contact@groupe-ka.com", }, ], }, barcodes: [ { format: "PKBarcodeFormatQR", message: `${BASE_URL}/m/${kaId}`, messageEncoding: "iso-8859-1", altText: kaId, }, ], }; await fs.writeFile( path.join(tmp, "pass.json"), JSON.stringify(pass, null, 2), ); // 2. icônes (embarquées dans le déploiement) const assets = path.join(process.cwd(), "assets", "wallet"); const files = ["pass.json"]; for (const f of [ "icon.png", "icon@2x.png", "icon@3x.png", "logo.png", "logo@2x.png", ]) { await fs.copyFile(path.join(assets, f), path.join(tmp, f)); files.push(f); } // Photo de profil téléversée → vignette du pass (thumbnail) const avatarPath = path.join(process.cwd(), "data", "avatars", `${kaId}.jpg`); try { const avatar = await fs.readFile(avatarPath); for (const [name, size] of [ ["thumbnail.png", 90], ["thumbnail@2x.png", 180], ["thumbnail@3x.png", 270], ] as const) { await fs.writeFile( path.join(tmp, name), await sharp(avatar) .resize(size, size, { fit: "cover" }) .png() .toBuffer(), ); files.push(name); } } catch { /* pas de photo téléversée : pass sans vignette */ } // 3. manifest.json — SHA-1 de chaque fichier const manifest: Record = {}; for (const f of files) { const buf = await fs.readFile(path.join(tmp, f)); manifest[f] = crypto.createHash("sha1").update(buf).digest("hex"); } await fs.writeFile( path.join(tmp, "manifest.json"), JSON.stringify(manifest), ); // 4. signature PKCS#7 détachée (openssl smime, DER) await run("openssl", [ "smime", "-binary", "-sign", "-certfile", path.join(certDir, "wwdr.pem"), "-signer", path.join(certDir, "pass-cert.pem"), "-inkey", path.join(certDir, "pass-key.pem"), "-in", path.join(tmp, "manifest.json"), "-out", path.join(tmp, "signature"), "-outform", "DER", ]); // 5. zip → .pkpass await run("zip", ["-q", "-j", "pass.pkpass", ...files, "manifest.json", "signature"], { cwd: tmp, }); const pkpass = await fs.readFile(path.join(tmp, "pass.pkpass")); return new NextResponse(new Uint8Array(pkpass), { headers: { "Content-Type": "application/vnd.apple.pkpass", "Content-Disposition": `attachment; filename="groupe-ka-${kaId}.pkpass"`, "Cache-Control": "no-store", }, }); } catch (err) { console.error("pkpass:", err); return NextResponse.json( { error: "Impossible de générer la carte Wallet — réessayez." }, { status: 500 }, ); } finally { await fs.rm(tmp, { recursive: true, force: true }); } }