strapi-plugin-publish-media-validation 1.0.1 → 1.1.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAO,MAAM,gBAAgB,CAAC;;2BAGlB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;uBAqC3B;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;qBACzB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;;AAE/C,wBAAgD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAO,MAAM,gBAAgB,CAAC;;2BAoHlB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;uBAiC3B;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;qBACzB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;;AAE/C,wBAAgD"}
@@ -1,7 +1,106 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
- const utils_1 = require("@strapi/utils");
6
+ const path_1 = __importDefault(require("path"));
7
+ function resolveValidationError() {
8
+ var _a, _b, _c;
9
+ const sep = path_1.default.sep;
10
+ for (const [filename, mod] of Object.entries((_a = require.cache) !== null && _a !== void 0 ? _a : {})) {
11
+ if (filename.includes(`@strapi${sep}utils`) &&
12
+ filename.endsWith('index.js') &&
13
+ ((_c = (_b = mod === null || mod === void 0 ? void 0 : mod.exports) === null || _b === void 0 ? void 0 : _b.errors) === null || _c === void 0 ? void 0 : _c.ValidationError)) {
14
+ return mod.exports.errors.ValidationError;
15
+ }
16
+ }
17
+ return class extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'ValidationError';
21
+ this.status = 400;
22
+ }
23
+ };
24
+ }
25
+ /**
26
+ * Recursively build a Strapi populate object that covers every media field
27
+ * (at any depth) inside the given model, including inside components and
28
+ * dynamic zones. The visited set prevents infinite loops from circular
29
+ * component references.
30
+ */
31
+ function buildPopulate(modelUID, strapi, visited = new Set()) {
32
+ var _a;
33
+ if (visited.has(modelUID))
34
+ return {};
35
+ const seen = new Set(visited);
36
+ seen.add(modelUID);
37
+ const model = strapi.getModel(modelUID);
38
+ if (!(model === null || model === void 0 ? void 0 : model.attributes))
39
+ return {};
40
+ const populate = {};
41
+ for (const [key, attr] of Object.entries(model.attributes)) {
42
+ const a = attr;
43
+ if (a.type === 'media') {
44
+ populate[key] = true;
45
+ }
46
+ else if (a.type === 'component') {
47
+ const nested = buildPopulate(a.component, strapi, seen);
48
+ // Always include the component so we can inspect its fields; fall back
49
+ // to populate:'*' when there are no explicitly known nested keys.
50
+ populate[key] = { populate: Object.keys(nested).length ? nested : '*' };
51
+ }
52
+ else if (a.type === 'dynamiczone') {
53
+ // Merge field names from every possible component type so that
54
+ // Strapi populates whichever fields exist on each concrete item.
55
+ const merged = {};
56
+ for (const compUID of ((_a = a.components) !== null && _a !== void 0 ? _a : [])) {
57
+ const nested = buildPopulate(compUID, strapi, seen);
58
+ Object.assign(merged, nested);
59
+ }
60
+ populate[key] = { populate: Object.keys(merged).length ? merged : '*' };
61
+ }
62
+ }
63
+ return populate;
64
+ }
65
+ /**
66
+ * Walk the fetched document data and collect human-readable paths for every
67
+ * required media field that is empty. Handles nested components and dynamic
68
+ * zones recursively.
69
+ */
70
+ function collectMissingMedia(data, modelUID, strapi) {
71
+ if (!data)
72
+ return [];
73
+ const model = strapi.getModel(modelUID);
74
+ if (!(model === null || model === void 0 ? void 0 : model.attributes))
75
+ return [];
76
+ const missing = [];
77
+ for (const [key, attr] of Object.entries(model.attributes)) {
78
+ const a = attr;
79
+ if (a.type === 'media' && a.required === true) {
80
+ if (!data[key]) {
81
+ missing.push(key);
82
+ }
83
+ }
84
+ else if (a.type === 'component' && data[key] != null) {
85
+ const items = Array.isArray(data[key]) ? data[key] : [data[key]];
86
+ items.forEach((item, i) => {
87
+ const prefix = Array.isArray(data[key]) ? `${key}[${i + 1}]` : key;
88
+ collectMissingMedia(item, a.component, strapi).forEach((f) => missing.push(`${prefix} > ${f}`));
89
+ });
90
+ }
91
+ else if (a.type === 'dynamiczone' && Array.isArray(data[key])) {
92
+ data[key].forEach((item, i) => {
93
+ const compUID = item === null || item === void 0 ? void 0 : item.__component;
94
+ if (compUID) {
95
+ collectMissingMedia(item, compUID, strapi).forEach((f) => missing.push(`${key}[${i + 1}] > ${f}`));
96
+ }
97
+ });
98
+ }
99
+ }
100
+ return missing;
101
+ }
4
102
  const register = ({ strapi }) => {
103
+ const ValidationError = resolveValidationError();
5
104
  strapi.documents.use(async (ctx, next) => {
6
105
  var _a;
7
106
  if (ctx.action !== 'publish')
@@ -9,25 +108,22 @@ const register = ({ strapi }) => {
9
108
  const model = strapi.getModel(ctx.uid);
10
109
  if (!(model === null || model === void 0 ? void 0 : model.attributes))
11
110
  return next();
12
- const requiredMediaFields = Object.entries(model.attributes)
13
- .filter(([, attr]) => attr.type === 'media' && attr.required === true)
14
- .map(([key]) => key);
15
- if (requiredMediaFields.length === 0)
16
- return next();
17
111
  const documentId = (_a = ctx.params) === null || _a === void 0 ? void 0 : _a.documentId;
18
112
  if (!documentId)
19
113
  return next();
20
- const populate = Object.fromEntries(requiredMediaFields.map((f) => [f, true]));
114
+ const populate = buildPopulate(ctx.uid, strapi);
115
+ if (Object.keys(populate).length === 0)
116
+ return next();
21
117
  const doc = await strapi.documents(ctx.uid).findOne({
22
118
  documentId,
23
119
  populate,
24
120
  });
25
- const missing = requiredMediaFields.filter((f) => !(doc === null || doc === void 0 ? void 0 : doc[f]));
121
+ const missing = collectMissingMedia(doc, ctx.uid, strapi);
26
122
  if (missing.length > 0) {
27
123
  const labels = missing
28
- .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1'))
124
+ .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').replace(/_/g, ' '))
29
125
  .join(', ');
30
- throw new utils_1.errors.ValidationError(`The following required media fields are empty: ${labels}`);
126
+ throw new ValidationError(`The following required media fields are empty: ${labels}`);
31
127
  }
32
128
  return next();
33
129
  });
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":";;AACA,yCAAuC;AAEvC,MAAM,QAAQ,GAAG,CAAC,EAAE,MAAM,EAA2B,EAAE,EAAE;IACvD,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;;QACvC,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS;YAAE,OAAO,IAAI,EAAE,CAAC;QAE5C,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAsB,CAAC,CAAC;QAC1D,IAAI,CAAC,CAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,UAAU,CAAA;YAAE,OAAO,IAAI,EAAE,CAAC;QAEtC,MAAM,mBAAmB,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC;aACzD,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,EAAE,CAAE,IAAY,CAAC,IAAI,KAAK,OAAO,IAAK,IAAY,CAAC,QAAQ,KAAK,IAAI,CAAC;aACvF,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC;QAEvB,IAAI,mBAAmB,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,EAAE,CAAC;QAEpD,MAAM,UAAU,GAAG,MAAC,GAAG,CAAC,MAAkC,0CAAE,UAAU,CAAC;QACvE,IAAI,CAAC,UAAU;YAAE,OAAO,IAAI,EAAE,CAAC;QAE/B,MAAM,QAAQ,GAAG,MAAM,CAAC,WAAW,CAAC,mBAAmB,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;QAC/E,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,GAAsB,CAAC,CAAC,OAAO,CAAC;YACrE,UAAU;YACV,QAAQ;SACT,CAAC,CAAC;QAEH,MAAM,OAAO,GAAG,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAW,aAAX,GAAG,uBAAH,GAAG,CAAW,CAAC,CAAC,CAAA,CAAC,CAAC;QAEtE,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACvB,MAAM,MAAM,GAAG,OAAO;iBACnB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;iBAC7E,IAAI,CAAC,IAAI,CAAC,CAAC;YACd,MAAM,IAAI,cAAM,CAAC,eAAe,CAC9B,kDAAkD,MAAM,EAAE,CAC3D,CAAC;QACJ,CAAC;QAED,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,MAAM,SAAS,GAAG,CAAC,KAA8B,EAAE,EAAE,GAAE,CAAC,CAAC;AACzD,MAAM,OAAO,GAAG,CAAC,KAA8B,EAAE,EAAE,GAAE,CAAC,CAAC;AAEvD,kBAAe,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":";;;;;AAAA,gDAAwB;AAKxB,SAAS,sBAAsB;;IAC7B,MAAM,GAAG,GAAG,cAAI,CAAC,GAAG,CAAC;IACrB,KAAK,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAA,OAAO,CAAC,KAAK,mCAAI,EAAE,CAAC,EAAE,CAAC;QAClE,IACE,QAAQ,CAAC,QAAQ,CAAC,UAAU,GAAG,OAAO,CAAC;YACvC,QAAQ,CAAC,QAAQ,CAAC,UAAU,CAAC;aAC7B,MAAA,MAAC,GAAW,aAAX,GAAG,uBAAH,GAAG,CAAU,OAAO,0CAAE,MAAM,0CAAE,eAAe,CAAA,EAC9C,CAAC;YACD,OAAQ,GAAW,CAAC,OAAO,CAAC,MAAM,CAAC,eAAe,CAAC;QACrD,CAAC;IACH,CAAC;IACD,OAAO,KAAM,SAAQ,KAAK;QACxB,YAAY,OAAe;YACzB,KAAK,CAAC,OAAO,CAAC,CAAC;YACf,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;YAC7B,IAAY,CAAC,MAAM,GAAG,GAAG,CAAC;QAC7B,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,SAAS,aAAa,CACpB,QAAgB,EAChB,MAAmB,EACnB,UAAU,IAAI,GAAG,EAAU;;IAE3B,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,EAAE,CAAC;IACrC,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;IAC9B,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAEnB,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAsB,CAAC,CAAC;IACtD,IAAI,CAAC,CAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,UAAU,CAAA;QAAE,OAAO,EAAE,CAAC;IAElC,MAAM,QAAQ,GAAwB,EAAE,CAAC;IAEzC,KAAK,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,CAAC;QAC3D,MAAM,CAAC,GAAG,IAAW,CAAC;QAEtB,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YACvB,QAAQ,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;QACvB,CAAC;aAAM,IAAI,CAAC,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;YAClC,MAAM,MAAM,GAAG,aAAa,CAAC,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC;YACxD,uEAAuE;YACvE,kEAAkE;YAClE,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;QAC1E,CAAC;aAAM,IAAI,CAAC,CAAC,IAAI,KAAK,aAAa,EAAE,CAAC;YACpC,+DAA+D;YAC/D,iEAAiE;YACjE,MAAM,MAAM,GAAwB,EAAE,CAAC;YACvC,KAAK,MAAM,OAAO,IAAI,CAAC,MAAA,CAAC,CAAC,UAAU,mCAAI,EAAE,CAAC,EAAE,CAAC;gBAC3C,MAAM,MAAM,GAAG,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC;gBACpD,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;YAChC,CAAC;YACD,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;QAC1E,CAAC;IACH,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;GAIG;AACH,SAAS,mBAAmB,CAC1B,IAAS,EACT,QAAgB,EAChB,MAAmB;IAEnB,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAC;IAErB,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAsB,CAAC,CAAC;IACtD,IAAI,CAAC,CAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,UAAU,CAAA;QAAE,OAAO,EAAE,CAAC;IAElC,MAAM,OAAO,GAAa,EAAE,CAAC;IAE7B,KAAK,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,CAAC;QAC3D,MAAM,CAAC,GAAG,IAAW,CAAC;QAEtB,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,IAAI,CAAC,CAAC,QAAQ,KAAK,IAAI,EAAE,CAAC;YAC9C,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBACf,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACH,CAAC;aAAM,IAAI,CAAC,CAAC,IAAI,KAAK,WAAW,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;YACvD,MAAM,KAAK,GAAU,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;YACxE,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;gBACxB,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;gBACnE,mBAAmB,CAAC,IAAI,EAAE,CAAC,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAC3D,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,MAAM,CAAC,EAAE,CAAC,CACjC,CAAC;YACJ,CAAC,CAAC,CAAC;QACL,CAAC;aAAM,IAAI,CAAC,CAAC,IAAI,KAAK,aAAa,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YAChE,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,IAAS,EAAE,CAAS,EAAE,EAAE;gBACzC,MAAM,OAAO,GAAuB,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,WAAW,CAAC;gBACtD,IAAI,OAAO,EAAE,CAAC;oBACZ,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CACvD,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,CACxC,CAAC;gBACJ,CAAC;YACH,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,QAAQ,GAAG,CAAC,EAAE,MAAM,EAA2B,EAAE,EAAE;IACvD,MAAM,eAAe,GAAG,sBAAsB,EAAE,CAAC;IAEjD,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;;QACvC,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS;YAAE,OAAO,IAAI,EAAE,CAAC;QAE5C,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAsB,CAAC,CAAC;QAC1D,IAAI,CAAC,CAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,UAAU,CAAA;YAAE,OAAO,IAAI,EAAE,CAAC;QAEtC,MAAM,UAAU,GAAG,MAAC,GAAG,CAAC,MAAkC,0CAAE,UAAU,CAAC;QACvE,IAAI,CAAC,UAAU;YAAE,OAAO,IAAI,EAAE,CAAC;QAE/B,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,CAAC,GAAa,EAAE,MAAM,CAAC,CAAC;QAC1D,IAAI,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,EAAE,CAAC;QAEtD,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,GAAsB,CAAC,CAAC,OAAO,CAAC;YACrE,UAAU;YACV,QAAQ;SACT,CAAC,CAAC;QAEH,MAAM,OAAO,GAAG,mBAAmB,CAAC,GAAG,EAAE,GAAG,CAAC,GAAa,EAAE,MAAM,CAAC,CAAC;QAEpE,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACvB,MAAM,MAAM,GAAG,OAAO;iBACnB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;iBAChG,IAAI,CAAC,IAAI,CAAC,CAAC;YACd,MAAM,IAAI,eAAe,CAAC,kDAAkD,MAAM,EAAE,CAAC,CAAC;QACxF,CAAC;QAED,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,MAAM,SAAS,GAAG,CAAC,KAA8B,EAAE,EAAE,GAAE,CAAC,CAAC;AACzD,MAAM,OAAO,GAAG,CAAC,KAA8B,EAAE,EAAE,GAAE,CAAC,CAAC;AAEvD,kBAAe,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-publish-media-validation",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Strapi v5 plugin that enforces required media fields at publish time — works around the known limitation where required: true on media fields is not validated on publish.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -13,7 +13,8 @@
13
13
  ],
14
14
  "main": "./dist/server/index.js",
15
15
  "exports": {
16
- "./strapi-server": "./dist/server/index.js"
16
+ "./strapi-server": "./dist/server/index.js",
17
+ "./package.json": "./package.json"
17
18
  },
18
19
  "files": [
19
20
  "dist",
@@ -40,8 +41,7 @@
40
41
  }
41
42
  },
42
43
  "peerDependencies": {
43
- "@strapi/strapi": ">=5.0.0",
44
- "@strapi/utils": ">=5.0.0"
44
+ "@strapi/strapi": ">=5.0.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@strapi/strapi": "5.44.0",
@@ -1,7 +1,22 @@
1
1
  import { errors } from '@strapi/utils';
2
2
  import plugin from '../index';
3
3
 
4
- // Capture the middleware function registered via strapi.documents.use()
4
+ const baseCtx = {
5
+ action: 'publish',
6
+ uid: 'api::article.article',
7
+ params: { documentId: 'abc123' },
8
+ };
9
+
10
+ const ARTICLE_UID = 'api::article.article';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Simple helper: getModel always returns the same model regardless of UID.
18
+ * Use for flat content-type tests with no components or dynamic zones.
19
+ */
5
20
  function buildStrapi(
6
21
  modelAttributes: Record<string, unknown> | null,
7
22
  docFields: Record<string, unknown> | null = {}
@@ -10,7 +25,7 @@ function buildStrapi(
10
25
 
11
26
  const strapi = {
12
27
  documents: Object.assign(
13
- (uid: string) => ({
28
+ (_uid: string) => ({
14
29
  findOne: jest.fn().mockResolvedValue(docFields),
15
30
  }),
16
31
  {
@@ -28,7 +43,7 @@ function buildStrapi(
28
43
 
29
44
  return {
30
45
  strapi,
31
- runMiddleware: (ctx: Record<string, unknown>) => {
46
+ runMiddleware: (ctx: Record<string, unknown> = baseCtx) => {
32
47
  if (!capturedMiddleware) throw new Error('middleware not registered');
33
48
  const next = jest.fn().mockResolvedValue(undefined);
34
49
  return { result: capturedMiddleware(ctx, next), next };
@@ -36,11 +51,48 @@ function buildStrapi(
36
51
  };
37
52
  }
38
53
 
39
- const baseCtx = {
40
- action: 'publish',
41
- uid: 'api::article.article',
42
- params: { documentId: 'abc123' },
43
- };
54
+ /**
55
+ * Multi-model helper: getModel returns different attributes per UID.
56
+ * Required for tests involving components or dynamic zones.
57
+ */
58
+ function buildStrapiFromModels(
59
+ modelsByUid: Record<string, Record<string, unknown>>,
60
+ docFields: Record<string, unknown> | null = {}
61
+ ) {
62
+ let capturedMiddleware: Function | null = null;
63
+
64
+ const strapi = {
65
+ documents: Object.assign(
66
+ (_uid: string) => ({
67
+ findOne: jest.fn().mockResolvedValue(docFields),
68
+ }),
69
+ {
70
+ use: jest.fn((fn: Function) => {
71
+ capturedMiddleware = fn;
72
+ }),
73
+ }
74
+ ),
75
+ getModel: jest.fn((uid: string) => {
76
+ const attrs = modelsByUid[uid];
77
+ return attrs !== undefined ? { attributes: attrs } : null;
78
+ }),
79
+ };
80
+
81
+ plugin.register({ strapi: strapi as any });
82
+
83
+ return {
84
+ strapi,
85
+ runMiddleware: (ctx: Record<string, unknown> = baseCtx) => {
86
+ if (!capturedMiddleware) throw new Error('middleware not registered');
87
+ const next = jest.fn().mockResolvedValue(undefined);
88
+ return { result: capturedMiddleware(ctx, next), next };
89
+ },
90
+ };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Registration
95
+ // ---------------------------------------------------------------------------
44
96
 
45
97
  describe('register', () => {
46
98
  it('calls strapi.documents.use once', () => {
@@ -49,7 +101,11 @@ describe('register', () => {
49
101
  });
50
102
  });
51
103
 
52
- describe('middleware', () => {
104
+ // ---------------------------------------------------------------------------
105
+ // Flat (top-level) media fields
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe('middleware — flat media fields', () => {
53
109
  it('passes through when action is not publish', async () => {
54
110
  const { runMiddleware } = buildStrapi({});
55
111
  const { next } = runMiddleware({ ...baseCtx, action: 'create' });
@@ -64,6 +120,15 @@ describe('middleware', () => {
64
120
  expect(next).toHaveBeenCalled();
65
121
  });
66
122
 
123
+ it('passes through when model has no media fields at all', async () => {
124
+ const { runMiddleware } = buildStrapi({
125
+ title: { type: 'string', required: true },
126
+ });
127
+ const { next } = runMiddleware(baseCtx);
128
+ await next;
129
+ expect(next).toHaveBeenCalled();
130
+ });
131
+
67
132
  it('passes through when model has no required media fields', async () => {
68
133
  const { runMiddleware } = buildStrapi({
69
134
  title: { type: 'string', required: true },
@@ -129,7 +194,7 @@ describe('middleware', () => {
129
194
  expect(next).toHaveBeenCalled();
130
195
  });
131
196
 
132
- it('populates only required media fields when fetching the document', async () => {
197
+ it('builds populate that includes all media fields when fetching the document', async () => {
133
198
  const findOne = jest.fn().mockResolvedValue({ cover: { id: 1 } });
134
199
  const strapi = {
135
200
  documents: Object.assign(() => ({ findOne }), {
@@ -151,3 +216,204 @@ describe('middleware', () => {
151
216
  });
152
217
  });
153
218
  });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Component fields
222
+ // ---------------------------------------------------------------------------
223
+
224
+ describe('middleware — component fields', () => {
225
+ const HERO_UID = 'sections.hero';
226
+
227
+ it('passes through when component required media is populated', async () => {
228
+ const { runMiddleware } = buildStrapiFromModels(
229
+ {
230
+ [ARTICLE_UID]: { hero: { type: 'component', component: HERO_UID } },
231
+ [HERO_UID]: { photo: { type: 'media', required: true } },
232
+ },
233
+ { hero: { photo: { id: 1 } } }
234
+ );
235
+ const { result, next } = runMiddleware();
236
+ await result;
237
+ expect(next).toHaveBeenCalled();
238
+ });
239
+
240
+ it('throws when component required media is empty', async () => {
241
+ const { runMiddleware } = buildStrapiFromModels(
242
+ {
243
+ [ARTICLE_UID]: { hero: { type: 'component', component: HERO_UID } },
244
+ [HERO_UID]: { photo: { type: 'media', required: true } },
245
+ },
246
+ { hero: { photo: null } }
247
+ );
248
+ const { result } = runMiddleware();
249
+ await expect(result).rejects.toThrow(errors.ValidationError);
250
+ await expect(result).rejects.toThrow('Hero > photo');
251
+ });
252
+
253
+ it('passes through when all repeatable component items have required media', async () => {
254
+ const { runMiddleware } = buildStrapiFromModels(
255
+ {
256
+ [ARTICLE_UID]: { cards: { type: 'component', component: HERO_UID, repeatable: true } },
257
+ [HERO_UID]: { photo: { type: 'media', required: true } },
258
+ },
259
+ { cards: [{ photo: { id: 1 } }, { photo: { id: 2 } }] }
260
+ );
261
+ const { result, next } = runMiddleware();
262
+ await result;
263
+ expect(next).toHaveBeenCalled();
264
+ });
265
+
266
+ it('reports the correct index for a missing repeatable component item', async () => {
267
+ const { runMiddleware } = buildStrapiFromModels(
268
+ {
269
+ [ARTICLE_UID]: { cards: { type: 'component', component: HERO_UID, repeatable: true } },
270
+ [HERO_UID]: { photo: { type: 'media', required: true } },
271
+ },
272
+ { cards: [{ photo: { id: 1 } }, { photo: null }] }
273
+ );
274
+ const { result } = runMiddleware();
275
+ await expect(result).rejects.toThrow('Cards[2] > photo');
276
+ });
277
+
278
+ it('builds populate that includes the component media field', async () => {
279
+ const findOne = jest.fn().mockResolvedValue({ hero: { photo: { id: 1 } } });
280
+ let capturedMiddleware: Function | null = null;
281
+ const strapi = {
282
+ documents: Object.assign(() => ({ findOne }), {
283
+ use: jest.fn((fn: Function) => {
284
+ capturedMiddleware = fn;
285
+ }),
286
+ }),
287
+ getModel: jest.fn((uid: string) => {
288
+ if (uid === ARTICLE_UID) return { attributes: { hero: { type: 'component', component: HERO_UID } } };
289
+ if (uid === HERO_UID) return { attributes: { photo: { type: 'media', required: true } } };
290
+ return null;
291
+ }),
292
+ };
293
+
294
+ plugin.register({ strapi: strapi as any });
295
+ await capturedMiddleware!(baseCtx, jest.fn().mockResolvedValue(undefined));
296
+
297
+ expect(findOne).toHaveBeenCalledWith({
298
+ documentId: 'abc123',
299
+ populate: { hero: { populate: { photo: true } } },
300
+ });
301
+ });
302
+ });
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Dynamic zone fields
306
+ // ---------------------------------------------------------------------------
307
+
308
+ describe('middleware — dynamiczone fields', () => {
309
+ const COPY_PHOTO_UID = 'sections.copy-photo';
310
+ const TEXT_UID = 'sections.text';
311
+
312
+ it('passes through when all blocks have required media populated', async () => {
313
+ const { runMiddleware } = buildStrapiFromModels(
314
+ {
315
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID] } },
316
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
317
+ },
318
+ { blocks: [{ __component: COPY_PHOTO_UID, photo: { id: 1 } }] }
319
+ );
320
+ const { result, next } = runMiddleware();
321
+ await result;
322
+ expect(next).toHaveBeenCalled();
323
+ });
324
+
325
+ it('throws when a block is missing required media', async () => {
326
+ const { runMiddleware } = buildStrapiFromModels(
327
+ {
328
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID] } },
329
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
330
+ },
331
+ { blocks: [{ __component: COPY_PHOTO_UID, photo: null }] }
332
+ );
333
+ const { result } = runMiddleware();
334
+ await expect(result).rejects.toThrow(errors.ValidationError);
335
+ await expect(result).rejects.toThrow('Blocks[1] > photo');
336
+ });
337
+
338
+ it('reports the correct block index in a multi-block dynamic zone', async () => {
339
+ const { runMiddleware } = buildStrapiFromModels(
340
+ {
341
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID] } },
342
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
343
+ },
344
+ {
345
+ blocks: [
346
+ { __component: COPY_PHOTO_UID, photo: { id: 1 } },
347
+ { __component: COPY_PHOTO_UID, photo: null },
348
+ ],
349
+ }
350
+ );
351
+ const { result } = runMiddleware();
352
+ await expect(result).rejects.toThrow('Blocks[2] > photo');
353
+ });
354
+
355
+ it('skips blocks whose component type has no required media', async () => {
356
+ const { runMiddleware } = buildStrapiFromModels(
357
+ {
358
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID, TEXT_UID] } },
359
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
360
+ [TEXT_UID]: { content: { type: 'richtext' } },
361
+ },
362
+ {
363
+ blocks: [
364
+ { __component: TEXT_UID, content: 'hello' },
365
+ { __component: COPY_PHOTO_UID, photo: { id: 1 } },
366
+ ],
367
+ }
368
+ );
369
+ const { result, next } = runMiddleware();
370
+ await result;
371
+ expect(next).toHaveBeenCalled();
372
+ });
373
+
374
+ it('throws only for the failing block in a mixed dynamic zone', async () => {
375
+ const { runMiddleware } = buildStrapiFromModels(
376
+ {
377
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID, TEXT_UID] } },
378
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
379
+ [TEXT_UID]: { content: { type: 'richtext' } },
380
+ },
381
+ {
382
+ blocks: [
383
+ { __component: TEXT_UID, content: 'hello' },
384
+ { __component: COPY_PHOTO_UID, photo: null },
385
+ ],
386
+ }
387
+ );
388
+ const { result } = runMiddleware();
389
+ await expect(result).rejects.toThrow('Blocks[2] > photo');
390
+ });
391
+
392
+ it('builds populate that merges media fields from all component types', async () => {
393
+ const findOne = jest.fn().mockResolvedValue({
394
+ blocks: [{ __component: COPY_PHOTO_UID, photo: { id: 1 } }],
395
+ });
396
+ let capturedMiddleware: Function | null = null;
397
+ const strapi = {
398
+ documents: Object.assign(() => ({ findOne }), {
399
+ use: jest.fn((fn: Function) => {
400
+ capturedMiddleware = fn;
401
+ }),
402
+ }),
403
+ getModel: jest.fn((uid: string) => {
404
+ if (uid === ARTICLE_UID) return { attributes: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID, TEXT_UID] } } };
405
+ if (uid === COPY_PHOTO_UID) return { attributes: { photo: { type: 'media', required: true } } };
406
+ if (uid === TEXT_UID) return { attributes: { content: { type: 'richtext' } } };
407
+ return null;
408
+ }),
409
+ };
410
+
411
+ plugin.register({ strapi: strapi as any });
412
+ await capturedMiddleware!(baseCtx, jest.fn().mockResolvedValue(undefined));
413
+
414
+ expect(findOne).toHaveBeenCalledWith({
415
+ documentId: 'abc123',
416
+ populate: { blocks: { populate: { photo: true } } },
417
+ });
418
+ });
419
+ });
@@ -1,37 +1,147 @@
1
+ import path from 'path';
1
2
  import type { Core, UID } from '@strapi/strapi';
2
- import { errors } from '@strapi/utils';
3
+
4
+ type ValidationErrorCtor = new (message: string) => Error;
5
+
6
+ function resolveValidationError(): ValidationErrorCtor {
7
+ const sep = path.sep;
8
+ for (const [filename, mod] of Object.entries(require.cache ?? {})) {
9
+ if (
10
+ filename.includes(`@strapi${sep}utils`) &&
11
+ filename.endsWith('index.js') &&
12
+ (mod as any)?.exports?.errors?.ValidationError
13
+ ) {
14
+ return (mod as any).exports.errors.ValidationError;
15
+ }
16
+ }
17
+ return class extends Error {
18
+ constructor(message: string) {
19
+ super(message);
20
+ this.name = 'ValidationError';
21
+ (this as any).status = 400;
22
+ }
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Recursively build a Strapi populate object that covers every media field
28
+ * (at any depth) inside the given model, including inside components and
29
+ * dynamic zones. The visited set prevents infinite loops from circular
30
+ * component references.
31
+ */
32
+ function buildPopulate(
33
+ modelUID: string,
34
+ strapi: Core.Strapi,
35
+ visited = new Set<string>()
36
+ ): Record<string, any> {
37
+ if (visited.has(modelUID)) return {};
38
+ const seen = new Set(visited);
39
+ seen.add(modelUID);
40
+
41
+ const model = strapi.getModel(modelUID as UID.Schema);
42
+ if (!model?.attributes) return {};
43
+
44
+ const populate: Record<string, any> = {};
45
+
46
+ for (const [key, attr] of Object.entries(model.attributes)) {
47
+ const a = attr as any;
48
+
49
+ if (a.type === 'media') {
50
+ populate[key] = true;
51
+ } else if (a.type === 'component') {
52
+ const nested = buildPopulate(a.component, strapi, seen);
53
+ // Always include the component so we can inspect its fields; fall back
54
+ // to populate:'*' when there are no explicitly known nested keys.
55
+ populate[key] = { populate: Object.keys(nested).length ? nested : '*' };
56
+ } else if (a.type === 'dynamiczone') {
57
+ // Merge field names from every possible component type so that
58
+ // Strapi populates whichever fields exist on each concrete item.
59
+ const merged: Record<string, any> = {};
60
+ for (const compUID of (a.components ?? [])) {
61
+ const nested = buildPopulate(compUID, strapi, seen);
62
+ Object.assign(merged, nested);
63
+ }
64
+ populate[key] = { populate: Object.keys(merged).length ? merged : '*' };
65
+ }
66
+ }
67
+
68
+ return populate;
69
+ }
70
+
71
+ /**
72
+ * Walk the fetched document data and collect human-readable paths for every
73
+ * required media field that is empty. Handles nested components and dynamic
74
+ * zones recursively.
75
+ */
76
+ function collectMissingMedia(
77
+ data: any,
78
+ modelUID: string,
79
+ strapi: Core.Strapi
80
+ ): string[] {
81
+ if (!data) return [];
82
+
83
+ const model = strapi.getModel(modelUID as UID.Schema);
84
+ if (!model?.attributes) return [];
85
+
86
+ const missing: string[] = [];
87
+
88
+ for (const [key, attr] of Object.entries(model.attributes)) {
89
+ const a = attr as any;
90
+
91
+ if (a.type === 'media' && a.required === true) {
92
+ if (!data[key]) {
93
+ missing.push(key);
94
+ }
95
+ } else if (a.type === 'component' && data[key] != null) {
96
+ const items: any[] = Array.isArray(data[key]) ? data[key] : [data[key]];
97
+ items.forEach((item, i) => {
98
+ const prefix = Array.isArray(data[key]) ? `${key}[${i + 1}]` : key;
99
+ collectMissingMedia(item, a.component, strapi).forEach((f) =>
100
+ missing.push(`${prefix} > ${f}`)
101
+ );
102
+ });
103
+ } else if (a.type === 'dynamiczone' && Array.isArray(data[key])) {
104
+ data[key].forEach((item: any, i: number) => {
105
+ const compUID: string | undefined = item?.__component;
106
+ if (compUID) {
107
+ collectMissingMedia(item, compUID, strapi).forEach((f) =>
108
+ missing.push(`${key}[${i + 1}] > ${f}`)
109
+ );
110
+ }
111
+ });
112
+ }
113
+ }
114
+
115
+ return missing;
116
+ }
3
117
 
4
118
  const register = ({ strapi }: { strapi: Core.Strapi }) => {
119
+ const ValidationError = resolveValidationError();
120
+
5
121
  strapi.documents.use(async (ctx, next) => {
6
122
  if (ctx.action !== 'publish') return next();
7
123
 
8
124
  const model = strapi.getModel(ctx.uid as UID.ContentType);
9
125
  if (!model?.attributes) return next();
10
126
 
11
- const requiredMediaFields = Object.entries(model.attributes)
12
- .filter(([, attr]) => (attr as any).type === 'media' && (attr as any).required === true)
13
- .map(([key]) => key);
14
-
15
- if (requiredMediaFields.length === 0) return next();
16
-
17
127
  const documentId = (ctx.params as { documentId?: string })?.documentId;
18
128
  if (!documentId) return next();
19
129
 
20
- const populate = Object.fromEntries(requiredMediaFields.map((f) => [f, true]));
130
+ const populate = buildPopulate(ctx.uid as string, strapi);
131
+ if (Object.keys(populate).length === 0) return next();
132
+
21
133
  const doc = await strapi.documents(ctx.uid as UID.ContentType).findOne({
22
134
  documentId,
23
135
  populate,
24
136
  });
25
137
 
26
- const missing = requiredMediaFields.filter((f) => !(doc as any)?.[f]);
138
+ const missing = collectMissingMedia(doc, ctx.uid as string, strapi);
27
139
 
28
140
  if (missing.length > 0) {
29
141
  const labels = missing
30
- .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1'))
142
+ .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').replace(/_/g, ' '))
31
143
  .join(', ');
32
- throw new errors.ValidationError(
33
- `The following required media fields are empty: ${labels}`
34
- );
144
+ throw new ValidationError(`The following required media fields are empty: ${labels}`);
35
145
  }
36
146
 
37
147
  return next();