I nomi utente potrebbero essere inclusi nel payload del webhook user_badge?

C’è la possibilità che i webhook user_badge possano includere anche username oltre a user_id? Attualmente il payload per un evento user_badge ha questo aspetto:

{
  "user_badge": {
    "id": 123456,
    "granted_at": "2026-09-25T14:54:42.073Z",
    "created_at": "2026-09-26T05:34:12.965Z",
    "post_id": 234567,
    "post_number": 1,
    "badge_id": 123,
    "user_id": 456,
    "granted_by_id": -1,
    "topic_id": 56789
  }
}

Ottenerlo username per un dato user_id (e granted_by_id, se non è -1) richiede una chiamata aggiuntiva a /admin/users/{user_id}.json, il che non solo richiede richieste aggiuntive, ma anche privilegi di amministratore, almeno a quanto riesco a capire.

Esiste un modo per ottenere username per un dato user_id utilizzando una chiave API senza aver bisogno di privilegi di amministratore?

Sto creando un servizio esterno che speravo di attivare quando vengono concessi determinati badge; tuttavia, ha bisogno del nome utente e i webhook dei badge restituiscono solo gli ID utente. :confused:

Comunque, il mio workaround per webhook non standard o quando ho bisogno di dati aggiuntivi è farlo tramite Workflows e poi inviare quello a n8n.

Per la cronaca, recuperare il nome utente dall’ID utente richiede solo una chiamata API, a meno che tu non stia assegnando manualmente migliaia di badge in un istante, nel qual caso dovresti essere al sicuro con i limiti API predefiniti.

Grazie! Darò un’occhiata a Workflows.

Ottimo sentirlo! Potresti fornirmi i dettagli su quella singola chiamata API che non è /admin/users/{user_id}.json (che richiede privilegi di amministratore)? Non sono riuscito a trovare un endpoint per farlo con una chiave API che non richiedesse privilegi di amministratore.

Qual è il problema con una chiave API di amministrazione? Questo dovrebbe essere un job in background, giusto?

Principio del minimo privilegio. Questo servizio esterno verrà eseguito su un altro server e ha bisogno solo di accesso in lettura a Discourse per svolgere il suo compito. Meno chiavi master in giro tra le infrastrutture, meglio è.

Questo solleva un punto interessante: perché non esiste un endpoint pubblico per ottenere un utente tramite ID che mostri solo i campi pubblici, quando login_required non è selezionato? Sarebbe utile, no? Mi chiedo perché non sia stato implementato. Forse solo gli utenti autenticati possono avere questo privilegio, ma in ogni caso dovrebbe esserci un modo per accedervi senza utilizzare l’endpoint di amministrazione…

@burke Un workaround più impegnativo sarebbe quello di consultare /groups/trust_level_0/members.json (o un gruppo più specifico) e trovare l’utente tramite il suo ID, poiché anche questa endpoint contiene il nome utente.

Ciò accade perché, per tutte le questioni e a tutti gli effetti, gli ID utente sono essenzialmente una convenzione interna in Discourse e ci si aspetta che si faccia affidamento sui nomi utente, che sono molto più evidenti in generale.

Torniamo così alla richiesta originale. Il webhook dovrebbe rispettare questo concetto e inviare il nome utente, non l’ID utente.

Concordo! Questa sembra la soluzione migliore. Aggiungere username e granted_by_username e deprecare user_id e granted_by_id potrebbe consentire un periodo di transizione senza interruzioni.

Attualmente, se un badge viene modificato dal sistema (cioè automaticamente), il webhook restituisce un granted_by_id pari a -1. Qual sarebbe l’equivalente per granted_by_username?

  • "" (stringa vuota)
  • "<system>" (un valore magico che non potrebbe essere un nome utente)
  • null
  • Niente – cioè, omettere granted_by_username dal payload se si è trattato di un evento gestito dal sistema

Presumo che null o l’omissione del campo siano le opzioni più sensate.

-1 è in realtà un ID utente valido che ha un nome utente (di solito system) associato, proprio come qualsiasi altro utente. Non c’è motivo di trattarlo in modo diverso.

Qui su meta https://meta.discourse.org/u/system/summary

Ah. Grazie per avermelo spiegato. Quindi, presumibilmente, granted_by_username sarebbe semplicemente "system" per le modifiche automatizzate.

@putty Ho iniziato a studiare Workflows. Finché gli eventi dei badge non includeranno i nomi utente (e forse indipendentemente da ciò, poiché posso usare i filtri in Workflows per inviare tramite POST solo le modifiche di cui il mio servizio ha bisogno), questa sembra un’ottima alternativa! Grazie ancora!

Con i webhook, c’è un X-Discourse-Event-Signature con una firma HMAC-SHA256 del payload che utilizza un segreto, il che mi permette di verificare il mittente. C’è la possibilità di firmare le richieste HTTP (POST) da Workflows? Forse non l’ho ancora trovata. Speravo ci fosse un’impostazione come “Firma richiesta usando segreto” che potessi attivare e a cui fornire un segreto. La cosa più vicina che ho visto è aggiungere una credenziale header_auth per inviare un segreto in un header (ad esempio, X-Discourse-Event-Secret: Discourse Rocks!). È meglio di niente, ma una firma utilizzando un segreto (senza inviare il segreto stesso) sarebbe preferibile.

Purtroppo anche io mi sono affidato all’invio del segreto tramite un header. Forse @j.jaffeux può suggerire un’alternativa migliore, che adotterò immediatamente anch’io :sweat_smile:

Ho creato un passaggio di codice per calcolare una firma. Esiste una Web Crypto API che può calcolare un HMAC-SHA256 molto più facilmente, ma utilizza una promise e questo non funziona all’interno di un eval. Quindi, ho chiesto a un’IA di creare un metodo JavaScript nativo per calcolare l’HMAC-SHA256, ho aggiunto una variabile con la chiave secret e il segreto come suo valore (, e sono finito con uno script di codice come questo:

// Variabili disponibili:
//   $input.item.json       - dati JSON dell'elemento corrente
//   $input.all()           - array di tutti gli elementi in ingresso
//   $json                  - scorciatoia per $input.item.json
//   $("NodeName").item     - accesso all'elemento abbinato da un altro nodo
//   $vars.KEY              - variabili di workflow
//   $site_settings.NAME    - impostazioni del sito
//   $execution             - metadati di esecuzione (id, workflow_name, ...)
//   $current_user          - utente che esegue il workflow
//   $helpers.absoluteUrl(path) - converte un percorso relativo in un URL assoluto
//   console.log/warn/error - logging

function hmacSha256(message, key) {
  function utf8Bytes(str) {
    const bytes = [];

    for (let i = 0; i < str.length; i++) {
      let code = str.charCodeAt(i);

      // Gestione delle coppie surrogate
      if (
        code >= 0xd800 &&
        code <= 0xdbff &&
        i + 1 < str.length
      ) {
        const next = str.charCodeAt(i + 1);

        if (next >= 0xdc00 && next <= 0xdfff) {
          code =
            0x10000 +
            ((code - 0xd800) << 10) +
            (next - 0xdc00);
          i++;
        }
      }

      if (code <= 0x7f) {
        bytes.push(code);
      } else if (code <= 0x7ff) {
        bytes.push(
          0xc0 | (code >> 6),
          0x80 | (code & 0x3f)
        );
      } else if (code <= 0xffff) {
        bytes.push(
          0xe0 | (code >> 12),
          0x80 | ((code >> 6) & 0x3f),
          0x80 | (code & 0x3f)
        );
      } else {
        bytes.push(
          0xf0 | (code >> 18),
          0x80 | ((code >> 12) & 0x3f),
          0x80 | ((code >> 6) & 0x3f),
          0x80 | (code & 0x3f)
        );
      }
    }

    return bytes;
  }

  function rotr(x, n) {
    return (x >>> n) | (x << (32 - n));
  }

  function sha256(bytes) {
    const K = [
      0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
      0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
      0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
      0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
      0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
      0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
      0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
      0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
      0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
      0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
      0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
      0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
      0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
      0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
      0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
      0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
    ];

    const H = [
      0x6a09e667,
      0xbb67ae85,
      0x3c6ef372,
      0xa54ff53a,
      0x510e527f,
      0x9b05688c,
      0x1f83d9ab,
      0x5be0cd19
    ];

    const data = bytes.slice();
    const bitLength = data.length * 8;

    // Padding SHA-256
    data.push(0x80);

    while ((data.length % 64) !== 56) {
      data.push(0);
    }

    // Aggiunge la lunghezza del messaggio in big-endian da 64 bit.
    // Gli interi JS sono sicuri qui per le dimensioni normali dei payload del workflow.
    const high = Math.floor(bitLength / 0x100000000);
    const low = bitLength >>> 0;

    data.push(
      (high >>> 24) & 0xff,
      (high >>> 16) & 0xff,
      (high >>> 8) & 0xff,
      high & 0xff,
      (low >>> 24) & 0xff,
      (low >>> 16) & 0xff,
      (low >>> 8) & 0xff,
      low & 0xff
    );

    const w = new Array(64);

    for (let offset = 0; offset < data.length; offset += 64) {
      for (let i = 0; i < 16; i++) {
        const j = offset + i * 4;

        w[i] =
          ((data[j] << 24) |
            (data[j + 1] << 16) |
            (data[j + 2] << 8) |
            data[j + 3]) >>> 0;
      }

      for (let i = 16; i < 64; i++) {
        const s0 =
          rotr(w[i - 15], 7) ^
          rotr(w[i - 15], 18) ^
          (w[i - 15] >>> 3);

        const s1 =
          rotr(w[i - 2], 17) ^
          rotr(w[i - 2], 19) ^
          (w[i - 2] >>> 10);

        w[i] =
          (
            w[i - 16] +
            s0 +
            w[i - 7] +
            s1
          ) >>> 0;
      }

      let a = H[0];
      let b = H[1];
      let c = H[2];
      let d = H[3];
      let e = H[4];
      let f = H[5];
      let g = H[6];
      let h = H[7];

      for (let i = 0; i < 64; i++) {
        const S1 =
          rotr(e, 6) ^
          rotr(e, 11) ^
          rotr(e, 25);

        const ch = (e & f) ^ (~e & g);

        const temp1 =
          (
            h +
            S1 +
            ch +
            K[i] +
            w[i]
          ) >>> 0;

        const S0 =
          rotr(a, 2) ^
          rotr(a, 13) ^
          rotr(a, 22);

        const maj =
          (a & b) ^
          (a & c) ^
          (b & c);

        const temp2 = (S0 + maj) >>> 0;

        h = g;
        g = f;
        f = e;
        e = (d + temp1) >>> 0;
        d = c;
        c = b;
        b = a;
        a = (temp1 + temp2) >>> 0;
      }

      H[0] = (H[0] + a) >>> 0;
      H[1] = (H[1] + b) >>> 0;
      H[2] = (H[2] + c) >>> 0;
      H[3] = (H[3] + d) >>> 0;
      H[4] = (H[4] + e) >>> 0;
      H[5] = (H[5] + f) >>> 0;
      H[6] = (H[6] + g) >>> 0;
      H[7] = (H[7] + h) >>> 0;
    }

    const result = [];

    for (let i = 0; i < H.length; i++) {
      result.push(
        (H[i] >>> 24) & 0xff,
        (H[i] >>> 16) & 0xff,
        (H[i] >>> 8) & 0xff,
        H[i] & 0xff
      );
    }

    return result;
  }

  function bytesToHex(bytes) {
    return bytes
      .map(b => b.toString(16).padStart(2, "0"))
      .join("");
  }

  const blockSize = 64;

  let keyBytes = utf8Bytes(key);
  const messageBytes = utf8Bytes(message);

  // Le chiavi più lunghe della dimensione del blocco SHA-256 vengono prima hashate.
  if (keyBytes.length > blockSize) {
    keyBytes = sha256(keyBytes);
  }

  // Riempi la chiave fino a 64 byte.
  while (keyBytes.length < blockSize) {
    keyBytes.push(0);
  }

  const innerPad = new Array(blockSize);
  const outerPad = new Array(blockSize);

  for (let i = 0; i < blockSize; i++) {
    innerPad[i] = keyBytes[i] ^ 0x36;
    outerPad[i] = keyBytes[i] ^ 0x5c;
  }

  const innerHash = sha256(
    innerPad.concat(messageBytes)
  );

  const hmac = sha256(
    outerPad.concat(innerHash)
  );

  return bytesToHex(hmac);
}

const payload = JSON.stringify({
    "the-json-payload": "goes-here"
});

const signature = "sha256=" + hmacSha256( payload, $vars.secret);

return {
    "payload": payload,
    "signature": signature
};

Questo è seguito da un passaggio di richiesta HTTP con un header con chiave X-Discourse-Workflow-Signature e valore {{ $("Code").item.json.signature }}, tipo di contenuto JSON e corpo della richiesta {{ $("Code").item.json.payload }}.

Il risultato è una richiesta HTTP come questa:

User-Agent: Faraday v2.14.4
Content-Length: 41
Accept: */*
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Content-Type: application/json
X-Discourse-Workflow-Signature: sha256=9ad2820b545013bdd18d4e7e6139624b91f576498e528201fa7759ec58dd598c

{"the-json-payload": "goes-here"}

X-Discourse-Workflow-Secret è una firma HMAC-SHA256 che utilizza il valore dell’argomento secret… proprio come un webhook firmato! :slight_smile:

Potrebbe non essere elegante, ma sembra che funzioni.

Aggiornamento: Ho prefissato la firma con sha256= per corrispondere allo stile delle firme dei webhook.

Sembra piuttosto autentico! Grazie per averlo condiviso!

Per la cronaca, ho testato questo HMAC-SHA256 nativo rispetto alla Web Crypto API per confermare che producano gli stessi risultati per messaggi e chiavi casuali, e le firme corrispondevano. Non posso garantire che non esistano casi particolari in cui divergano, ma sono abbastanza soddisfatto da usarlo in produzione. :slightly_smiling_face: