spb/groupe-ka Public
Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 85.5%
HTML 8.9%
CSS 5.5%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Carte de membre Groupe KA pour Apple Wallet (.pkpass).3// Assemble pass.json + icônes, calcule le manifest (SHA-1), signe en PKCS#74// détaché via openssl (certificat Pass Type ID + WWDR), zippe et sert.5// Config .env.local du nœud :6// KA_PASS_DIR dossier contenant pass-cert.pem, pass-key.pem, wwdr.pem7// KA_PASS_TYPE_ID ex. pass.com.groupe-ka.membre8// KA_PASS_TEAM_ID ex. 3YM54G49SN9import { NextResponse } from "next/server";10import { execFile } from "child_process";11import { promisify } from "util";12import crypto from "crypto";13import fs from "fs/promises";14import os from "os";15import path from "path";16import sharp from "sharp";17import { getSessionUser, BASE_URL } from "@/lib/auth";18import { ensureKaId } from "@/lib/db";19import { roleCard } from "@/lib/roles";2021const run = promisify(execFile);2223function fmtDate(iso: string | null): string {24 if (!iso) return "";25 const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");26 if (Number.isNaN(d.getTime())) return "";27 return d.toLocaleDateString("fr-CA", {28 day: "numeric",29 month: "long",30 year: "numeric",31 });32}3334export async function GET() {35 const user = await getSessionUser();36 if (!user)37 return NextResponse.redirect(38 new URL("/connexion?next=%2Fcompte", BASE_URL),39 );4041 const certDir = process.env.KA_PASS_DIR;42 const passTypeId = process.env.KA_PASS_TYPE_ID;43 const teamId = process.env.KA_PASS_TEAM_ID;44 if (!certDir || !passTypeId || !teamId)45 return NextResponse.json(46 {47 error:48 "Apple Wallet pas encore activé — certificat Pass Type ID en attente (KA_PASS_DIR / KA_PASS_TYPE_ID / KA_PASS_TEAM_ID).",49 },50 { status: 503 },51 );5253 const kaId = user.kaId ?? ensureKaId(user.id);54 const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "kapass-"));55 try {56 // 1. pass.json — carte générique aux couleurs du groupe57 const pass = {58 formatVersion: 1,59 passTypeIdentifier: passTypeId,60 teamIdentifier: teamId,61 serialNumber: kaId,62 organizationName: "Groupe KA",63 description: "Carte de membre Groupe KA",64 logoText: "",65 foregroundColor: "rgb(245,243,238)",66 backgroundColor: "rgb(20,24,20)",67 labelColor: "rgb(217,242,107)",68 generic: {69 primaryFields: [{ key: "kaid", label: "KA-ID", value: kaId }],70 secondaryFields: [71 { key: "membre", label: "MEMBRE", value: user.name || user.email },72 ...(roleCard(user.role)73 ? [{ key: "statut", label: "STATUT", value: roleCard(user.role)! }]74 : []),75 ],76 auxiliaryFields: [77 ...(fmtDate(user.createdAt)78 ? [79 {80 key: "depuis",81 label: "MEMBRE DEPUIS",82 value: fmtDate(user.createdAt),83 },84 ]85 : []),86 {87 key: "portee",88 label: "VALIDE SUR",89 value: "Les 7 plateformes ·Ka",90 },91 ],92 backFields: [93 {94 key: "verif",95 label: "Vérification",96 value: `${BASE_URL}/m/${kaId}`,97 },98 {99 key: "plateformes",100 label: "Plateformes",101 value:102 "Lou·Ka · Immo·Ka · Vrai-Prix · ValoPlex · Auto·Ka · Fabri·Ka · Food·Ka",103 },104 {105 key: "contact",106 label: "Contact",107 value: "contact@groupe-ka.com",108 },109 ],110 },111 barcodes: [112 {113 format: "PKBarcodeFormatQR",114 message: `${BASE_URL}/m/${kaId}`,115 messageEncoding: "iso-8859-1",116 altText: kaId,117 },118 ],119 };120 await fs.writeFile(121 path.join(tmp, "pass.json"),122 JSON.stringify(pass, null, 2),123 );124125 // 2. icônes (embarquées dans le déploiement)126 const assets = path.join(process.cwd(), "assets", "wallet");127 const files = ["pass.json"];128 for (const f of [129 "icon.png",130 "icon@2x.png",131 "icon@3x.png",132 "logo.png",133 "logo@2x.png",134 ]) {135 await fs.copyFile(path.join(assets, f), path.join(tmp, f));136 files.push(f);137 }138139 // Photo de profil téléversée → vignette du pass (thumbnail)140 const avatarPath = path.join(process.cwd(), "data", "avatars", `${kaId}.jpg`);141 try {142 const avatar = await fs.readFile(avatarPath);143 for (const [name, size] of [144 ["thumbnail.png", 90],145 ["thumbnail@2x.png", 180],146 ["thumbnail@3x.png", 270],147 ] as const) {148 await fs.writeFile(149 path.join(tmp, name),150 await sharp(avatar)151 .resize(size, size, { fit: "cover" })152 .png()153 .toBuffer(),154 );155 files.push(name);156 }157 } catch {158 /* pas de photo téléversée : pass sans vignette */159 }160161 // 3. manifest.json — SHA-1 de chaque fichier162 const manifest: Record<string, string> = {};163 for (const f of files) {164 const buf = await fs.readFile(path.join(tmp, f));165 manifest[f] = crypto.createHash("sha1").update(buf).digest("hex");166 }167 await fs.writeFile(168 path.join(tmp, "manifest.json"),169 JSON.stringify(manifest),170 );171172 // 4. signature PKCS#7 détachée (openssl smime, DER)173 await run("openssl", [174 "smime",175 "-binary",176 "-sign",177 "-certfile",178 path.join(certDir, "wwdr.pem"),179 "-signer",180 path.join(certDir, "pass-cert.pem"),181 "-inkey",182 path.join(certDir, "pass-key.pem"),183 "-in",184 path.join(tmp, "manifest.json"),185 "-out",186 path.join(tmp, "signature"),187 "-outform",188 "DER",189 ]);190191 // 5. zip → .pkpass192 await run("zip", ["-q", "-j", "pass.pkpass", ...files, "manifest.json", "signature"], {193 cwd: tmp,194 });195 const pkpass = await fs.readFile(path.join(tmp, "pass.pkpass"));196197 return new NextResponse(new Uint8Array(pkpass), {198 headers: {199 "Content-Type": "application/vnd.apple.pkpass",200 "Content-Disposition": `attachment; filename="groupe-ka-${kaId}.pkpass"`,201 "Cache-Control": "no-store",202 },203 });204 } catch (err) {205 console.error("pkpass:", err);206 return NextResponse.json(207 { error: "Impossible de générer la carte Wallet — réessayez." },208 { status: 500 },209 );210 } finally {211 await fs.rm(tmp, { recursive: true, force: true });212 }213}214