strapi-plugin-hubspot 0.11.1 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +45 -0
- package/dist/_chunks/{Forms-DadTbe9Q.mjs → Forms-CHB-sKem.mjs} +218 -121
- package/dist/_chunks/{Forms-Dn2W6OS4.js → Forms-lrzuWzoB.js} +217 -120
- package/dist/_chunks/{HubspotFormInput-S_Z2t_DN.js → HubspotFormInput-Bb6it0nP.js} +1 -1
- package/dist/_chunks/{HubspotFormInput-NO59AtM_.mjs → HubspotFormInput-YI09keTC.mjs} +1 -1
- package/dist/_chunks/{HubspotObjectInput-DAo3FAMz.mjs → HubspotObjectInput-CzLKT9oj.mjs} +3 -3
- package/dist/_chunks/{HubspotObjectInput-BIgIa_Dm.js → HubspotObjectInput-D3A6xzb3.js} +3 -3
- package/dist/_chunks/{HubspotPropertyInput-KX1Bhcsl.mjs → HubspotPropertyInput-DicskLcN.mjs} +3 -3
- package/dist/_chunks/{HubspotPropertyInput-B-piLcIy.js → HubspotPropertyInput-Dss3eUlL.js} +3 -3
- package/dist/_chunks/{Settings-Dz0PEFOv.mjs → Settings-4CbX3SPK.mjs} +2 -2
- package/dist/_chunks/{Settings-DMu_QThB.js → Settings-Ch8tPZAV.js} +2 -2
- package/dist/_chunks/{en-CH5LSPkL.mjs → en-D8DJiSxd.mjs} +3 -1
- package/dist/_chunks/{en-BDGqW_yS.js → en-PYFibnDc.js} +3 -1
- package/dist/_chunks/{fr-65-RaB3w.mjs → fr-BMjixfXn.mjs} +3 -1
- package/dist/_chunks/{fr-C82LMqUM.js → fr-yHk4sSEl.js} +3 -1
- package/dist/_chunks/{index-B7Ru1fu5.js → index-BAvedd2m.js} +6 -6
- package/dist/_chunks/{index-DA31TY2n.mjs → index-BE2ribUL.mjs} +6 -6
- package/dist/_chunks/{objectLabels-FTh984_h.js → objectLabels-CycoU0zY.js} +1 -1
- package/dist/_chunks/{objectLabels-Bl7sO5pb.mjs → objectLabels-DGJxYjKz.mjs} +1 -1
- package/dist/_chunks/{useHubspotSchema-C2_PuXEm.js → useHubspotSchema-DRfaI6mF.js} +1 -1
- package/dist/_chunks/{useHubspotSchema-BlDilCHc.mjs → useHubspotSchema-e31UsYb2.mjs} +1 -1
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/src/builder/Sortable.d.ts +25 -0
- package/dist/admin/src/builder/__tests__/CompanyMapEditor.test.d.ts +1 -0
- package/dist/admin/src/builder/__tests__/ConditionEditor.test.d.ts +1 -0
- package/dist/admin/src/builder/__tests__/testUtils.d.ts +4 -0
- package/dist/server/index.js +109 -42
- package/dist/server/index.mjs +109 -42
- package/dist/server/src/__tests__/contract.test.d.ts +1 -0
- package/dist/server/src/__tests__/rateLimit.test.d.ts +1 -0
- package/dist/server/src/index.d.ts +12 -3
- package/dist/server/src/rateLimit.d.ts +18 -0
- package/package.json +11 -2
|
@@ -25,7 +25,7 @@ const index = {
|
|
|
25
25
|
icon: PaperPlane,
|
|
26
26
|
intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "HubSpot Forms" },
|
|
27
27
|
permissions: [{ action: `plugin::${PLUGIN_ID}.forms`, subject: null }],
|
|
28
|
-
Component: async () => (await import("./Forms-
|
|
28
|
+
Component: async () => (await import("./Forms-CHB-sKem.mjs")).default
|
|
29
29
|
});
|
|
30
30
|
app.customFields.register({
|
|
31
31
|
name: "property",
|
|
@@ -40,7 +40,7 @@ const index = {
|
|
|
40
40
|
defaultMessage: "Picked from the portal's real properties"
|
|
41
41
|
},
|
|
42
42
|
components: {
|
|
43
|
-
Input: async () => import("./HubspotPropertyInput-
|
|
43
|
+
Input: async () => import("./HubspotPropertyInput-DicskLcN.mjs")
|
|
44
44
|
},
|
|
45
45
|
// Exposed in the Content-Type Builder. Without this, `objectField` could
|
|
46
46
|
// only be set by hand-editing schema.json — which is not how anyone
|
|
@@ -103,7 +103,7 @@ const index = {
|
|
|
103
103
|
defaultMessage: "Picked from the portal's configured objects"
|
|
104
104
|
},
|
|
105
105
|
components: {
|
|
106
|
-
Input: async () => import("./HubspotObjectInput-
|
|
106
|
+
Input: async () => import("./HubspotObjectInput-CzLKT9oj.mjs")
|
|
107
107
|
}
|
|
108
108
|
});
|
|
109
109
|
app.customFields.register({
|
|
@@ -119,7 +119,7 @@ const index = {
|
|
|
119
119
|
defaultMessage: "A form built in the HubSpot form builder, stored by slug"
|
|
120
120
|
},
|
|
121
121
|
components: {
|
|
122
|
-
Input: async () => import("./HubspotFormInput-
|
|
122
|
+
Input: async () => import("./HubspotFormInput-YI09keTC.mjs")
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
125
|
app.createSettingSection(
|
|
@@ -135,7 +135,7 @@ const index = {
|
|
|
135
135
|
// Mirrors the RBAC action registered server-side: a role without it
|
|
136
136
|
// doesn't see the link at all — the routes refuse it anyway.
|
|
137
137
|
permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
|
|
138
|
-
Component: async () => (await import("./Settings-
|
|
138
|
+
Component: async () => (await import("./Settings-4CbX3SPK.mjs")).default
|
|
139
139
|
}
|
|
140
140
|
]
|
|
141
141
|
);
|
|
@@ -145,7 +145,7 @@ const index = {
|
|
|
145
145
|
return Promise.all(
|
|
146
146
|
locales.map(async (locale) => {
|
|
147
147
|
try {
|
|
148
|
-
const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-
|
|
148
|
+
const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-D8DJiSxd.mjs"), "./translations/fr.json": () => import("./fr-BMjixfXn.mjs") }), `./translations/${locale}.json`, 3);
|
|
149
149
|
return { data: prefixPluginTranslations(data), locale };
|
|
150
150
|
} catch {
|
|
151
151
|
return { data: {}, locale };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const React = require("react");
|
|
3
3
|
const admin = require("@strapi/strapi/admin");
|
|
4
|
-
const index = require("./index-
|
|
4
|
+
const index = require("./index-BAvedd2m.js");
|
|
5
5
|
function _interopNamespace(e) {
|
|
6
6
|
if (e && e.__esModule) return e;
|
|
7
7
|
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { useFetchClient } from "@strapi/strapi/admin";
|
|
3
|
-
import { P as PLUGIN_ID } from "./index-
|
|
3
|
+
import { P as PLUGIN_ID } from "./index-BE2ribUL.mjs";
|
|
4
4
|
let cached = null;
|
|
5
5
|
let inFlight = null;
|
|
6
6
|
const listeners = /* @__PURE__ */ new Set();
|
package/dist/admin/index.js
CHANGED
package/dist/admin/index.mjs
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { arrayMove } from "@dnd-kit/sortable";
|
|
3
|
+
export { arrayMove };
|
|
4
|
+
/** The drag-handle props to spread on the grip element. */
|
|
5
|
+
export interface HandleProps {
|
|
6
|
+
attributes: React.HTMLAttributes<HTMLElement>;
|
|
7
|
+
listeners: Record<string, unknown> | undefined;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* One vertical sortable list. Each list gets its OWN DndContext: steps sort
|
|
11
|
+
* among steps, a step's fields sort among themselves — no cross-container
|
|
12
|
+
* ambiguity, and the arrow buttons keep covering cross-step moves (they also
|
|
13
|
+
* stay the accessible path).
|
|
14
|
+
*/
|
|
15
|
+
export declare function SortableList({ ids, onReorder, children, }: {
|
|
16
|
+
ids: string[];
|
|
17
|
+
/** Called with the reordered ids after a completed drag. */
|
|
18
|
+
onReorder: (ids: string[]) => void;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}): React.JSX.Element;
|
|
21
|
+
/** A sortable row: wraps the card, hands the grip props to its children. */
|
|
22
|
+
export declare function SortableItem({ id, children, }: {
|
|
23
|
+
id: string;
|
|
24
|
+
children: (handle: HandleProps) => React.ReactNode;
|
|
25
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/server/index.js
CHANGED
|
@@ -1710,6 +1710,10 @@ function createFormsService(strapi, _opts = {}) {
|
|
|
1710
1710
|
const path = opts.meta.originPath ? ` (${escapeHtml(String(opts.meta.originPath))})` : "";
|
|
1711
1711
|
lines.push(`<strong>Sujet</strong> : ${escapeHtml(String(opts.meta.originLabel))}${path}`);
|
|
1712
1712
|
}
|
|
1713
|
+
if (opts.meta.consent === true) {
|
|
1714
|
+
const when = typeof opts.meta.consentedAt === "string" && opts.meta.consentedAt ? ` (${escapeHtml(opts.meta.consentedAt)})` : "";
|
|
1715
|
+
lines.push(`<strong>Consentement RGPD</strong> : oui${when}`);
|
|
1716
|
+
}
|
|
1713
1717
|
for (const record of opts.companies ?? []) {
|
|
1714
1718
|
if (!record.name && !record.siret) continue;
|
|
1715
1719
|
const bits = [record.name, record.siret ? `SIRET ${record.siret}` : ""].filter(Boolean);
|
|
@@ -1753,6 +1757,10 @@ function createFormsService(strapi, _opts = {}) {
|
|
|
1753
1757
|
});
|
|
1754
1758
|
}
|
|
1755
1759
|
async function submit(form, rawValues, meta = {}) {
|
|
1760
|
+
const hp = rawValues.__hp;
|
|
1761
|
+
if (typeof hp === "string" && hp.trim() !== "") {
|
|
1762
|
+
return { ok: true, hubspotSynced: false };
|
|
1763
|
+
}
|
|
1756
1764
|
const resolution = resolveSubmission(form.definition, rawValues);
|
|
1757
1765
|
if (resolution.missingRequired.length) {
|
|
1758
1766
|
return { ok: false, missingRequired: resolution.missingRequired };
|
|
@@ -2510,6 +2518,36 @@ function createFormsAdminController(strapi) {
|
|
|
2510
2518
|
}
|
|
2511
2519
|
};
|
|
2512
2520
|
}
|
|
2521
|
+
function createLimiter({
|
|
2522
|
+
windowMs,
|
|
2523
|
+
max,
|
|
2524
|
+
now = Date.now
|
|
2525
|
+
}) {
|
|
2526
|
+
const hits = /* @__PURE__ */ new Map();
|
|
2527
|
+
let lastSweep = 0;
|
|
2528
|
+
return {
|
|
2529
|
+
allow(key) {
|
|
2530
|
+
if (max <= 0) return true;
|
|
2531
|
+
const at = now();
|
|
2532
|
+
const floor = at - windowMs;
|
|
2533
|
+
if (at - lastSweep >= windowMs) {
|
|
2534
|
+
lastSweep = at;
|
|
2535
|
+
for (const [k, stamps] of hits) {
|
|
2536
|
+
if (!stamps.some((t) => t > floor)) hits.delete(k);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
const recent = (hits.get(key) ?? []).filter((t) => t > floor);
|
|
2540
|
+
if (recent.length >= max) {
|
|
2541
|
+
hits.set(key, recent);
|
|
2542
|
+
return false;
|
|
2543
|
+
}
|
|
2544
|
+
recent.push(at);
|
|
2545
|
+
hits.set(key, recent);
|
|
2546
|
+
return true;
|
|
2547
|
+
},
|
|
2548
|
+
size: () => hits.size
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2513
2551
|
function fieldOrder(definition) {
|
|
2514
2552
|
if (!definition) return [];
|
|
2515
2553
|
return definition.steps.flatMap((step) => step.fields.map((field) => field.name));
|
|
@@ -2697,7 +2735,12 @@ const config = {
|
|
|
2697
2735
|
// - companyFromDomain: a corporate email upserts the Company (deduped by
|
|
2698
2736
|
// domain) and associates it to the contact;
|
|
2699
2737
|
// - timelineNote: a note recaps the submission on the contact's timeline.
|
|
2700
|
-
forms: {
|
|
2738
|
+
forms: {
|
|
2739
|
+
companyFromDomain: true,
|
|
2740
|
+
timelineNote: true,
|
|
2741
|
+
// Per-IP brakes on the public routes; 0 disables one.
|
|
2742
|
+
rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
|
|
2743
|
+
}
|
|
2701
2744
|
},
|
|
2702
2745
|
validator(cfg) {
|
|
2703
2746
|
if (cfg.validate && !Array.isArray(cfg.validate)) {
|
|
@@ -2759,13 +2802,23 @@ const controllers = {
|
|
|
2759
2802
|
}
|
|
2760
2803
|
}),
|
|
2761
2804
|
formsAdmin: ({ strapi }) => createFormsAdminController(strapi),
|
|
2762
|
-
company: () =>
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
}
|
|
2768
|
-
|
|
2805
|
+
company: ({ strapi }) => {
|
|
2806
|
+
const limits = strapi.plugin("hubspot").config("forms", {});
|
|
2807
|
+
const limiter = createLimiter({
|
|
2808
|
+
windowMs: 6e4,
|
|
2809
|
+
max: limits.rateLimit?.searchPerMinute ?? 30
|
|
2810
|
+
});
|
|
2811
|
+
return {
|
|
2812
|
+
/** SIRENE autocomplete for the company field — bounded, cached upstream. */
|
|
2813
|
+
async search(ctx) {
|
|
2814
|
+
if (!limiter.allow(ctx.request.ip ?? "unknown")) {
|
|
2815
|
+
ctx.throw(429, "Too many searches — slow down.");
|
|
2816
|
+
}
|
|
2817
|
+
const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
|
|
2818
|
+
ctx.body = { companies: await searchCompanies(q) };
|
|
2819
|
+
}
|
|
2820
|
+
};
|
|
2821
|
+
},
|
|
2769
2822
|
submissions: ({ strapi }) => ({
|
|
2770
2823
|
/** Paged submissions, newest first, optionally narrowed to one form. */
|
|
2771
2824
|
async list(ctx) {
|
|
@@ -2809,41 +2862,55 @@ const controllers = {
|
|
|
2809
2862
|
};
|
|
2810
2863
|
}
|
|
2811
2864
|
}),
|
|
2812
|
-
forms: ({ strapi }) =>
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
}
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
if (
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
ctx.
|
|
2841
|
-
|
|
2842
|
-
|
|
2865
|
+
forms: ({ strapi }) => {
|
|
2866
|
+
const limits = strapi.plugin("hubspot").config("forms", {});
|
|
2867
|
+
const submitLimiter = createLimiter({
|
|
2868
|
+
windowMs: 6e4,
|
|
2869
|
+
max: limits.rateLimit?.submitPerMinute ?? 6
|
|
2870
|
+
});
|
|
2871
|
+
return {
|
|
2872
|
+
/** Published form for the host frontend — CRM mapping stripped. */
|
|
2873
|
+
async findOne(ctx) {
|
|
2874
|
+
const entry = await strapi.documents(FORM_UID).findFirst({
|
|
2875
|
+
filters: { slug: ctx.params.slug },
|
|
2876
|
+
status: "published",
|
|
2877
|
+
locale: ctx.query.locale
|
|
2878
|
+
});
|
|
2879
|
+
if (!entry?.definition) ctx.throw(404, "Form not found");
|
|
2880
|
+
ctx.body = publicForm(entry);
|
|
2881
|
+
},
|
|
2882
|
+
async submit(ctx) {
|
|
2883
|
+
const entry = await strapi.documents(FORM_UID).findFirst({
|
|
2884
|
+
filters: { slug: ctx.params.slug },
|
|
2885
|
+
status: "published",
|
|
2886
|
+
locale: ctx.query.locale
|
|
2887
|
+
});
|
|
2888
|
+
if (!entry?.definition) ctx.throw(404, "Form not found");
|
|
2889
|
+
if (!submitLimiter.allow(ctx.request.ip ?? "unknown")) {
|
|
2890
|
+
ctx.throw(429, "Too many submissions — try again in a minute.");
|
|
2891
|
+
}
|
|
2892
|
+
const values = sanitizeRawValues(ctx.request.body?.values);
|
|
2893
|
+
if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
|
|
2894
|
+
const rawMeta = ctx.request.body?.meta ?? {};
|
|
2895
|
+
const meta = {};
|
|
2896
|
+
for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
|
|
2897
|
+
const v = rawMeta[key];
|
|
2898
|
+
if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
|
|
2899
|
+
}
|
|
2900
|
+
if (rawMeta.consent === true) meta.consent = true;
|
|
2901
|
+
if (typeof rawMeta.consentedAt === "string" && rawMeta.consentedAt.trim()) {
|
|
2902
|
+
meta.consentedAt = rawMeta.consentedAt.trim().slice(0, 40);
|
|
2903
|
+
}
|
|
2904
|
+
const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
|
|
2905
|
+
if (!outcome.ok) {
|
|
2906
|
+
ctx.status = 422;
|
|
2907
|
+
ctx.body = { ok: false, missingRequired: outcome.missingRequired ?? [] };
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
ctx.body = { ok: true, hubspotSynced: outcome.hubspotSynced };
|
|
2843
2911
|
}
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
}),
|
|
2912
|
+
};
|
|
2913
|
+
},
|
|
2847
2914
|
settings: ({ strapi }) => ({
|
|
2848
2915
|
async get(ctx) {
|
|
2849
2916
|
ctx.body = await publicSettings(strapi);
|
package/dist/server/index.mjs
CHANGED
|
@@ -1709,6 +1709,10 @@ function createFormsService(strapi, _opts = {}) {
|
|
|
1709
1709
|
const path = opts.meta.originPath ? ` (${escapeHtml(String(opts.meta.originPath))})` : "";
|
|
1710
1710
|
lines.push(`<strong>Sujet</strong> : ${escapeHtml(String(opts.meta.originLabel))}${path}`);
|
|
1711
1711
|
}
|
|
1712
|
+
if (opts.meta.consent === true) {
|
|
1713
|
+
const when = typeof opts.meta.consentedAt === "string" && opts.meta.consentedAt ? ` (${escapeHtml(opts.meta.consentedAt)})` : "";
|
|
1714
|
+
lines.push(`<strong>Consentement RGPD</strong> : oui${when}`);
|
|
1715
|
+
}
|
|
1712
1716
|
for (const record of opts.companies ?? []) {
|
|
1713
1717
|
if (!record.name && !record.siret) continue;
|
|
1714
1718
|
const bits = [record.name, record.siret ? `SIRET ${record.siret}` : ""].filter(Boolean);
|
|
@@ -1752,6 +1756,10 @@ function createFormsService(strapi, _opts = {}) {
|
|
|
1752
1756
|
});
|
|
1753
1757
|
}
|
|
1754
1758
|
async function submit(form, rawValues, meta = {}) {
|
|
1759
|
+
const hp = rawValues.__hp;
|
|
1760
|
+
if (typeof hp === "string" && hp.trim() !== "") {
|
|
1761
|
+
return { ok: true, hubspotSynced: false };
|
|
1762
|
+
}
|
|
1755
1763
|
const resolution = resolveSubmission(form.definition, rawValues);
|
|
1756
1764
|
if (resolution.missingRequired.length) {
|
|
1757
1765
|
return { ok: false, missingRequired: resolution.missingRequired };
|
|
@@ -2509,6 +2517,36 @@ function createFormsAdminController(strapi) {
|
|
|
2509
2517
|
}
|
|
2510
2518
|
};
|
|
2511
2519
|
}
|
|
2520
|
+
function createLimiter({
|
|
2521
|
+
windowMs,
|
|
2522
|
+
max,
|
|
2523
|
+
now = Date.now
|
|
2524
|
+
}) {
|
|
2525
|
+
const hits = /* @__PURE__ */ new Map();
|
|
2526
|
+
let lastSweep = 0;
|
|
2527
|
+
return {
|
|
2528
|
+
allow(key) {
|
|
2529
|
+
if (max <= 0) return true;
|
|
2530
|
+
const at = now();
|
|
2531
|
+
const floor = at - windowMs;
|
|
2532
|
+
if (at - lastSweep >= windowMs) {
|
|
2533
|
+
lastSweep = at;
|
|
2534
|
+
for (const [k, stamps] of hits) {
|
|
2535
|
+
if (!stamps.some((t) => t > floor)) hits.delete(k);
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
const recent = (hits.get(key) ?? []).filter((t) => t > floor);
|
|
2539
|
+
if (recent.length >= max) {
|
|
2540
|
+
hits.set(key, recent);
|
|
2541
|
+
return false;
|
|
2542
|
+
}
|
|
2543
|
+
recent.push(at);
|
|
2544
|
+
hits.set(key, recent);
|
|
2545
|
+
return true;
|
|
2546
|
+
},
|
|
2547
|
+
size: () => hits.size
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2512
2550
|
function fieldOrder(definition) {
|
|
2513
2551
|
if (!definition) return [];
|
|
2514
2552
|
return definition.steps.flatMap((step) => step.fields.map((field) => field.name));
|
|
@@ -2696,7 +2734,12 @@ const config = {
|
|
|
2696
2734
|
// - companyFromDomain: a corporate email upserts the Company (deduped by
|
|
2697
2735
|
// domain) and associates it to the contact;
|
|
2698
2736
|
// - timelineNote: a note recaps the submission on the contact's timeline.
|
|
2699
|
-
forms: {
|
|
2737
|
+
forms: {
|
|
2738
|
+
companyFromDomain: true,
|
|
2739
|
+
timelineNote: true,
|
|
2740
|
+
// Per-IP brakes on the public routes; 0 disables one.
|
|
2741
|
+
rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
|
|
2742
|
+
}
|
|
2700
2743
|
},
|
|
2701
2744
|
validator(cfg) {
|
|
2702
2745
|
if (cfg.validate && !Array.isArray(cfg.validate)) {
|
|
@@ -2758,13 +2801,23 @@ const controllers = {
|
|
|
2758
2801
|
}
|
|
2759
2802
|
}),
|
|
2760
2803
|
formsAdmin: ({ strapi }) => createFormsAdminController(strapi),
|
|
2761
|
-
company: () =>
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2804
|
+
company: ({ strapi }) => {
|
|
2805
|
+
const limits = strapi.plugin("hubspot").config("forms", {});
|
|
2806
|
+
const limiter = createLimiter({
|
|
2807
|
+
windowMs: 6e4,
|
|
2808
|
+
max: limits.rateLimit?.searchPerMinute ?? 30
|
|
2809
|
+
});
|
|
2810
|
+
return {
|
|
2811
|
+
/** SIRENE autocomplete for the company field — bounded, cached upstream. */
|
|
2812
|
+
async search(ctx) {
|
|
2813
|
+
if (!limiter.allow(ctx.request.ip ?? "unknown")) {
|
|
2814
|
+
ctx.throw(429, "Too many searches — slow down.");
|
|
2815
|
+
}
|
|
2816
|
+
const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
|
|
2817
|
+
ctx.body = { companies: await searchCompanies(q) };
|
|
2818
|
+
}
|
|
2819
|
+
};
|
|
2820
|
+
},
|
|
2768
2821
|
submissions: ({ strapi }) => ({
|
|
2769
2822
|
/** Paged submissions, newest first, optionally narrowed to one form. */
|
|
2770
2823
|
async list(ctx) {
|
|
@@ -2808,41 +2861,55 @@ const controllers = {
|
|
|
2808
2861
|
};
|
|
2809
2862
|
}
|
|
2810
2863
|
}),
|
|
2811
|
-
forms: ({ strapi }) =>
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
if (
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
ctx.
|
|
2840
|
-
|
|
2841
|
-
|
|
2864
|
+
forms: ({ strapi }) => {
|
|
2865
|
+
const limits = strapi.plugin("hubspot").config("forms", {});
|
|
2866
|
+
const submitLimiter = createLimiter({
|
|
2867
|
+
windowMs: 6e4,
|
|
2868
|
+
max: limits.rateLimit?.submitPerMinute ?? 6
|
|
2869
|
+
});
|
|
2870
|
+
return {
|
|
2871
|
+
/** Published form for the host frontend — CRM mapping stripped. */
|
|
2872
|
+
async findOne(ctx) {
|
|
2873
|
+
const entry = await strapi.documents(FORM_UID).findFirst({
|
|
2874
|
+
filters: { slug: ctx.params.slug },
|
|
2875
|
+
status: "published",
|
|
2876
|
+
locale: ctx.query.locale
|
|
2877
|
+
});
|
|
2878
|
+
if (!entry?.definition) ctx.throw(404, "Form not found");
|
|
2879
|
+
ctx.body = publicForm(entry);
|
|
2880
|
+
},
|
|
2881
|
+
async submit(ctx) {
|
|
2882
|
+
const entry = await strapi.documents(FORM_UID).findFirst({
|
|
2883
|
+
filters: { slug: ctx.params.slug },
|
|
2884
|
+
status: "published",
|
|
2885
|
+
locale: ctx.query.locale
|
|
2886
|
+
});
|
|
2887
|
+
if (!entry?.definition) ctx.throw(404, "Form not found");
|
|
2888
|
+
if (!submitLimiter.allow(ctx.request.ip ?? "unknown")) {
|
|
2889
|
+
ctx.throw(429, "Too many submissions — try again in a minute.");
|
|
2890
|
+
}
|
|
2891
|
+
const values = sanitizeRawValues(ctx.request.body?.values);
|
|
2892
|
+
if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
|
|
2893
|
+
const rawMeta = ctx.request.body?.meta ?? {};
|
|
2894
|
+
const meta = {};
|
|
2895
|
+
for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
|
|
2896
|
+
const v = rawMeta[key];
|
|
2897
|
+
if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
|
|
2898
|
+
}
|
|
2899
|
+
if (rawMeta.consent === true) meta.consent = true;
|
|
2900
|
+
if (typeof rawMeta.consentedAt === "string" && rawMeta.consentedAt.trim()) {
|
|
2901
|
+
meta.consentedAt = rawMeta.consentedAt.trim().slice(0, 40);
|
|
2902
|
+
}
|
|
2903
|
+
const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
|
|
2904
|
+
if (!outcome.ok) {
|
|
2905
|
+
ctx.status = 422;
|
|
2906
|
+
ctx.body = { ok: false, missingRequired: outcome.missingRequired ?? [] };
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
ctx.body = { ok: true, hubspotSynced: outcome.hubspotSynced };
|
|
2842
2910
|
}
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
}),
|
|
2911
|
+
};
|
|
2912
|
+
},
|
|
2846
2913
|
settings: ({ strapi }) => ({
|
|
2847
2914
|
async get(ctx) {
|
|
2848
2915
|
ctx.body = await publicSettings(strapi);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -10,6 +10,10 @@ declare const _default: {
|
|
|
10
10
|
forms: {
|
|
11
11
|
companyFromDomain: boolean;
|
|
12
12
|
timelineNote: boolean;
|
|
13
|
+
rateLimit: {
|
|
14
|
+
submitPerMinute: number;
|
|
15
|
+
searchPerMinute: number;
|
|
16
|
+
};
|
|
13
17
|
};
|
|
14
18
|
};
|
|
15
19
|
validator(cfg: {
|
|
@@ -27,7 +31,6 @@ declare const _default: {
|
|
|
27
31
|
displayName: string;
|
|
28
32
|
description: string;
|
|
29
33
|
};
|
|
30
|
-
/** RBAC action gating the settings screen and its routes. */
|
|
31
34
|
options: {
|
|
32
35
|
draftAndPublish: boolean;
|
|
33
36
|
};
|
|
@@ -36,7 +39,7 @@ declare const _default: {
|
|
|
36
39
|
visible: boolean;
|
|
37
40
|
};
|
|
38
41
|
"content-type-builder": {
|
|
39
|
-
visible: boolean;
|
|
42
|
+
visible: boolean; /** RBAC action gating the form builder and its routes. */
|
|
40
43
|
};
|
|
41
44
|
i18n: {
|
|
42
45
|
localized: boolean;
|
|
@@ -265,13 +268,19 @@ declare const _default: {
|
|
|
265
268
|
duplicate(ctx: import("./formsAdmin").Ctx): Promise<void>;
|
|
266
269
|
remove(ctx: import("./formsAdmin").Ctx): Promise<void>;
|
|
267
270
|
};
|
|
268
|
-
company: (
|
|
271
|
+
company: ({ strapi }: {
|
|
272
|
+
strapi: Core.Strapi;
|
|
273
|
+
}) => {
|
|
269
274
|
/** SIRENE autocomplete for the company field — bounded, cached upstream. */
|
|
270
275
|
search(ctx: {
|
|
271
276
|
query: {
|
|
272
277
|
q?: string;
|
|
273
278
|
};
|
|
279
|
+
request: {
|
|
280
|
+
ip?: string;
|
|
281
|
+
};
|
|
274
282
|
body: unknown;
|
|
283
|
+
throw: (s: number, m: string) => never;
|
|
275
284
|
}): Promise<void>;
|
|
276
285
|
};
|
|
277
286
|
submissions: ({ strapi }: {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory sliding-window rate limiter for the public routes. Per-process on
|
|
3
|
+
* purpose: a form submission endpoint needs a cheap brake against a single
|
|
4
|
+
* abusive IP, not distributed accounting — and a multi-instance deployment
|
|
5
|
+
* simply multiplies the budget by the instance count, which is fine.
|
|
6
|
+
*/
|
|
7
|
+
export interface Limiter {
|
|
8
|
+
/** True when the call fits the window; false = refuse (429). */
|
|
9
|
+
allow(key: string): boolean;
|
|
10
|
+
/** Number of tracked keys — a test hook for the sweep. */
|
|
11
|
+
size(): number;
|
|
12
|
+
}
|
|
13
|
+
export declare function createLimiter({ windowMs, max, now, }: {
|
|
14
|
+
windowMs: number;
|
|
15
|
+
/** 0 disables the limiter entirely. */
|
|
16
|
+
max: number;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
}): Limiter;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strapi-plugin-hubspot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "The form builder for HubSpot in Strapi — multi-step forms with conditional fields mapped to real CRM properties, a public submit pipeline with retries, and save-time mapping validation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"strapi",
|
|
@@ -69,10 +69,14 @@
|
|
|
69
69
|
"@strapi/strapi": "^5.0.0",
|
|
70
70
|
"@strapi/typescript-utils": "^5.0.0",
|
|
71
71
|
"@strapi/utils": "^5.0.0",
|
|
72
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
73
|
+
"@testing-library/react": "^16.3.3",
|
|
74
|
+
"@testing-library/user-event": "^14.6.6",
|
|
72
75
|
"@types/react": "^18.3.3",
|
|
73
76
|
"@types/react-dom": "^18.3.0",
|
|
74
77
|
"eslint": "^10.9.1",
|
|
75
78
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
79
|
+
"jsdom": "^29.1.1",
|
|
76
80
|
"react": "^18.3.1",
|
|
77
81
|
"react-dom": "^18.3.1",
|
|
78
82
|
"react-router-dom": "^6.26.0",
|
|
@@ -99,5 +103,10 @@
|
|
|
99
103
|
},
|
|
100
104
|
"engines": {
|
|
101
105
|
"node": ">=18.0.0"
|
|
106
|
+
},
|
|
107
|
+
"dependencies": {
|
|
108
|
+
"@dnd-kit/core": "^6.3.1",
|
|
109
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
110
|
+
"@dnd-kit/utilities": "^3.2.2"
|
|
102
111
|
}
|
|
103
|
-
}
|
|
112
|
+
}
|