SPB Git

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%
6.4 KB · 214 lines typescript
Raw Blame History
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