@kontainer/strapi-plugin 0.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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/admin/KontainerMediaInput-C6wPHxqb.js +148 -0
  4. package/dist/admin/KontainerMediaInput-DmEdEpue.mjs +130 -0
  5. package/dist/admin/Settings-fZTH8z6T.js +152 -0
  6. package/dist/admin/Settings-tNEBWX1o.mjs +134 -0
  7. package/dist/admin/da-Be8uFPrt.js +28 -0
  8. package/dist/admin/da-WHaTm2Cy.mjs +28 -0
  9. package/dist/admin/en-B3JWZj1Z.mjs +28 -0
  10. package/dist/admin/en-B7EZhOY4.js +28 -0
  11. package/dist/admin/index-CMAfPTry.js +121 -0
  12. package/dist/admin/index-hpPTiq45.mjs +122 -0
  13. package/dist/admin/index.js +4 -0
  14. package/dist/admin/index.mjs +4 -0
  15. package/dist/admin/src/components/Initializer.d.ts +5 -0
  16. package/dist/admin/src/components/KontainerMediaInput.d.ts +3 -0
  17. package/dist/admin/src/components/PluginIcon.d.ts +2 -0
  18. package/dist/admin/src/index.d.ts +3 -0
  19. package/dist/admin/src/pages/Settings.d.ts +3 -0
  20. package/dist/admin/src/pluginId.d.ts +1 -0
  21. package/dist/admin/src/utils/getTranslation.d.ts +2 -0
  22. package/dist/server/index.js +356 -0
  23. package/dist/server/index.mjs +356 -0
  24. package/dist/server/src/bootstrap.d.ts +5 -0
  25. package/dist/server/src/config/index.d.ts +9 -0
  26. package/dist/server/src/content-types/index.d.ts +2 -0
  27. package/dist/server/src/controllers/controller.d.ts +11 -0
  28. package/dist/server/src/controllers/index.d.ts +12 -0
  29. package/dist/server/src/destroy.d.ts +5 -0
  30. package/dist/server/src/index.d.ts +108 -0
  31. package/dist/server/src/middlewares/index.d.ts +2 -0
  32. package/dist/server/src/policies/index.d.ts +2 -0
  33. package/dist/server/src/register.d.ts +5 -0
  34. package/dist/server/src/routes/admin/index.d.ts +12 -0
  35. package/dist/server/src/routes/content-api/index.d.ts +12 -0
  36. package/dist/server/src/routes/index.d.ts +25 -0
  37. package/dist/server/src/services/index.d.ts +51 -0
  38. package/dist/server/src/services/service.d.ts +57 -0
  39. package/package.json +91 -0
@@ -0,0 +1,356 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const node_http = require("node:http");
4
+ const node_https = require("node:https");
5
+ const bootstrap = async ({ strapi }) => {
6
+ const url = await strapi.plugin("kontainer").service("service").getUrl();
7
+ if (!url) return;
8
+ try {
9
+ const host = new URL(url).host;
10
+ const fileUrl = strapi.plugin("kontainer").config("url", "");
11
+ const fileHost = fileUrl ? new URL(fileUrl).host : "";
12
+ const covered = host === "kontainer.com" || host.endsWith(".kontainer.com") || host === fileHost;
13
+ if (!covered) {
14
+ strapi.log.warn(
15
+ `[kontainer] ${host} is not covered by the plugin's Content-Security-Policy patch. Thumbnails may be blocked in the admin. Set KONTAINER_URL (config/plugins) to ${url} or add ${host} to img-src in config/middlewares.`
16
+ );
17
+ }
18
+ } catch {
19
+ }
20
+ };
21
+ const destroy = ({ strapi }) => {
22
+ };
23
+ const KONTAINER_CSP_SOURCES = ["https://*.kontainer.com", "https://kontainer.com"];
24
+ const patchContentSecurityPolicy = (strapi) => {
25
+ const sources = [...KONTAINER_CSP_SOURCES];
26
+ const fileUrl = strapi.plugin("kontainer").config("url", "");
27
+ if (fileUrl) {
28
+ try {
29
+ const u = new URL(fileUrl);
30
+ sources.push(`${u.protocol}//${u.host}`);
31
+ } catch {
32
+ }
33
+ }
34
+ const middlewares2 = strapi.config.get("middlewares");
35
+ const patched = middlewares2.map((mw) => {
36
+ const isPlain = mw === "strapi::security";
37
+ const entry = mw;
38
+ if (!isPlain && entry?.name !== "strapi::security") return mw;
39
+ const config2 = isPlain ? {} : entry.config ?? {};
40
+ const csp = config2.contentSecurityPolicy ?? {};
41
+ const directives = csp.directives ?? {};
42
+ const baseImg = ["'self'", "data:", "blob:", "https://market-assets.strapi.io"];
43
+ const baseMedia = ["'self'", "data:", "blob:"];
44
+ const merge = (existing, base) => [
45
+ .../* @__PURE__ */ new Set([...existing ?? base, ...sources])
46
+ ];
47
+ return {
48
+ name: "strapi::security",
49
+ config: {
50
+ ...config2,
51
+ contentSecurityPolicy: {
52
+ ...csp,
53
+ useDefaults: csp.useDefaults ?? true,
54
+ directives: {
55
+ ...directives,
56
+ "img-src": merge(directives["img-src"], baseImg),
57
+ "media-src": merge(directives["media-src"], baseMedia)
58
+ }
59
+ }
60
+ }
61
+ };
62
+ });
63
+ strapi.config.set("middlewares", patched);
64
+ };
65
+ const register = ({ strapi }) => {
66
+ strapi.customFields.register({
67
+ name: "media",
68
+ plugin: "kontainer",
69
+ type: "json"
70
+ });
71
+ patchContentSecurityPolicy(strapi);
72
+ };
73
+ const config = {
74
+ default: {
75
+ // Kontainer tenant URL the picker opens, e.g. https://yourcompany.kontainer.com
76
+ url: ""
77
+ },
78
+ validator(config2) {
79
+ if (config2.url && typeof config2.url !== "string") {
80
+ throw new Error("kontainer plugin: `url` must be a string");
81
+ }
82
+ }
83
+ };
84
+ const contentTypes = {};
85
+ const isValidUrl = (value) => {
86
+ try {
87
+ const u = new URL(value);
88
+ return u.protocol === "https:" || u.protocol === "http:";
89
+ } catch {
90
+ return false;
91
+ }
92
+ };
93
+ const controller = ({ strapi }) => ({
94
+ // Which entries reference a given Kontainer file id.
95
+ async usage(ctx) {
96
+ const { fileId } = ctx.params;
97
+ if (!fileId) {
98
+ return ctx.badRequest("fileId is required");
99
+ }
100
+ const data = await strapi.plugin("kontainer").service("service").findUsage(String(fileId));
101
+ ctx.body = { fileId: String(fileId), count: data.length, data };
102
+ },
103
+ // Effective picker URL for the admin input component.
104
+ async config(ctx) {
105
+ ctx.body = { url: await strapi.plugin("kontainer").service("service").getUrl() };
106
+ },
107
+ async getSettings(ctx) {
108
+ ctx.body = await strapi.plugin("kontainer").service("service").getSettings();
109
+ },
110
+ async validateSettings(ctx) {
111
+ const url = String(ctx.query.url ?? "");
112
+ if (!url) {
113
+ return ctx.badRequest("url query parameter is required");
114
+ }
115
+ ctx.body = await strapi.plugin("kontainer").service("service").validateUrl(url);
116
+ },
117
+ async updateSettings(ctx) {
118
+ const { url } = ctx.request.body ?? {};
119
+ if (typeof url !== "string" || url !== "" && !isValidUrl(url)) {
120
+ return ctx.badRequest("url must be empty or a valid http(s) URL");
121
+ }
122
+ await strapi.plugin("kontainer").service("service").setSettings({ url: url.replace(/\/+$/, "") });
123
+ ctx.body = await strapi.plugin("kontainer").service("service").getSettings();
124
+ }
125
+ });
126
+ const controllers = {
127
+ controller
128
+ };
129
+ const middlewares = {};
130
+ const policies = {};
131
+ const contentAPIRoutes = () => ({
132
+ type: "content-api",
133
+ routes: [
134
+ {
135
+ method: "GET",
136
+ path: "/usage/:fileId",
137
+ handler: "controller.usage",
138
+ config: {
139
+ policies: []
140
+ }
141
+ }
142
+ ]
143
+ });
144
+ const adminAPIRoutes = () => ({
145
+ type: "admin",
146
+ routes: [
147
+ {
148
+ method: "GET",
149
+ path: "/config",
150
+ handler: "controller.config",
151
+ config: {
152
+ policies: ["admin::isAuthenticatedAdmin"]
153
+ }
154
+ },
155
+ {
156
+ method: "GET",
157
+ path: "/settings",
158
+ handler: "controller.getSettings",
159
+ config: {
160
+ policies: ["admin::isAuthenticatedAdmin"]
161
+ }
162
+ },
163
+ {
164
+ method: "PUT",
165
+ path: "/settings",
166
+ handler: "controller.updateSettings",
167
+ config: {
168
+ policies: ["admin::isAuthenticatedAdmin"]
169
+ }
170
+ },
171
+ {
172
+ method: "GET",
173
+ path: "/settings/validate",
174
+ handler: "controller.validateSettings",
175
+ config: {
176
+ policies: ["admin::isAuthenticatedAdmin"]
177
+ }
178
+ }
179
+ ]
180
+ });
181
+ const routes = {
182
+ "content-api": contentAPIRoutes,
183
+ admin: adminAPIRoutes
184
+ };
185
+ const CUSTOM_FIELD = "plugin::kontainer.media";
186
+ const fetchLocationHeader = (probeUrl) => new Promise((resolve, reject) => {
187
+ const request = probeUrl.protocol === "https:" ? node_https.request : node_http.request;
188
+ const req = request(
189
+ probeUrl,
190
+ {
191
+ method: "GET",
192
+ timeout: 5e3,
193
+ // local dev instances use self-signed certificates
194
+ rejectUnauthorized: process.env.NODE_ENV === "production"
195
+ },
196
+ (res) => {
197
+ res.destroy();
198
+ resolve(res.headers.location);
199
+ }
200
+ );
201
+ req.on("timeout", () => req.destroy(new Error("timeout")));
202
+ req.on("error", reject);
203
+ req.end();
204
+ });
205
+ const referencesFile = (value, fileId) => {
206
+ if (Array.isArray(value)) {
207
+ return value.some((v) => referencesFile(v, fileId));
208
+ }
209
+ if (value && typeof value === "object") {
210
+ const obj = value;
211
+ if (obj.fileId !== void 0 && String(obj.fileId) === fileId) return true;
212
+ return Object.values(obj).some((v) => referencesFile(v, fileId));
213
+ }
214
+ return false;
215
+ };
216
+ const service = ({ strapi }) => ({
217
+ settingsStore() {
218
+ return strapi.store({ type: "plugin", name: "kontainer" });
219
+ },
220
+ // Admin-panel setting wins; config/plugins (env) is the fallback.
221
+ async getUrl() {
222
+ const stored = await this.settingsStore().get({ key: "settings" });
223
+ const url = stored?.url || strapi.plugin("kontainer").config("url", "");
224
+ return url.replace(/\/+$/, "");
225
+ },
226
+ async getSettings() {
227
+ const stored = await this.settingsStore().get({ key: "settings" });
228
+ return {
229
+ url: stored?.url ?? "",
230
+ fileUrl: strapi.plugin("kontainer").config("url", "")
231
+ };
232
+ },
233
+ async setSettings(settings) {
234
+ await this.settingsStore().set({ key: "settings", value: settings });
235
+ },
236
+ // Is the URL an actual Kontainer instance? The picker entry point
237
+ // (/?cmsMode=1) redirects to ?cmsContextId=<uuid> — a stable fingerprint
238
+ // that works unauthenticated.
239
+ async validateUrl(raw) {
240
+ let url;
241
+ try {
242
+ url = new URL(raw);
243
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
244
+ throw new Error("unsupported protocol");
245
+ }
246
+ } catch {
247
+ return { valid: false, reason: "invalid-url" };
248
+ }
249
+ try {
250
+ const location = await fetchLocationHeader(
251
+ new URL(`${url.protocol}//${url.host}/?cmsMode=1`)
252
+ );
253
+ return location?.includes("cmsContextId=") ? { valid: true } : { valid: false, reason: "not-kontainer" };
254
+ } catch {
255
+ return { valid: false, reason: "unreachable" };
256
+ }
257
+ },
258
+ // Does this component (or any component nested in it) use the custom field?
259
+ componentHasKontainerField(uid, seen = /* @__PURE__ */ new Set()) {
260
+ if (seen.has(uid)) return false;
261
+ seen.add(uid);
262
+ const attributes = strapi.components[uid]?.attributes ?? {};
263
+ return Object.values(attributes).some((attr) => {
264
+ if (attr.customField === CUSTOM_FIELD) return true;
265
+ if (attr.type === "component") {
266
+ return this.componentHasKontainerField(attr.component, seen);
267
+ }
268
+ if (attr.type === "dynamiczone") {
269
+ return attr.components.some(
270
+ (c) => this.componentHasKontainerField(c, seen)
271
+ );
272
+ }
273
+ return false;
274
+ });
275
+ },
276
+ // Populate spec covering every path to a kontainer field inside a component.
277
+ populateForComponent(uid) {
278
+ const attributes = strapi.components[uid]?.attributes ?? {};
279
+ const inner = this.populateForAttributes(attributes);
280
+ return Object.keys(inner).length ? { populate: inner } : true;
281
+ },
282
+ populateForAttributes(attributes) {
283
+ const populate = {};
284
+ for (const [name, attr] of Object.entries(attributes)) {
285
+ if (attr.type === "component" && this.componentHasKontainerField(attr.component)) {
286
+ populate[name] = this.populateForComponent(attr.component);
287
+ } else if (attr.type === "dynamiczone") {
288
+ const withField = attr.components.filter(
289
+ (c) => this.componentHasKontainerField(c)
290
+ );
291
+ if (withField.length) {
292
+ populate[name] = {
293
+ on: Object.fromEntries(
294
+ withField.map((c) => [c, this.populateForComponent(c)])
295
+ )
296
+ };
297
+ }
298
+ }
299
+ }
300
+ return populate;
301
+ },
302
+ async findUsage(fileId) {
303
+ const usage = [];
304
+ for (const [uid, contentType] of Object.entries(strapi.contentTypes)) {
305
+ if (!uid.startsWith("api::")) continue;
306
+ const attributes = contentType.attributes;
307
+ const directFields = Object.entries(attributes).filter(([, attr]) => attr.customField === CUSTOM_FIELD).map(([name]) => name);
308
+ const populate = this.populateForAttributes(attributes);
309
+ const candidateFields = [...directFields, ...Object.keys(populate)];
310
+ if (!candidateFields.length) continue;
311
+ for (const status of ["draft", "published"]) {
312
+ const pageSize = 500;
313
+ for (let start = 0; ; start += pageSize) {
314
+ const entries = await strapi.documents(uid).findMany({
315
+ status,
316
+ populate,
317
+ locale: "*",
318
+ start,
319
+ limit: pageSize
320
+ });
321
+ for (const entry of entries) {
322
+ for (const field of candidateFields) {
323
+ if (!referencesFile(entry[field], fileId)) continue;
324
+ usage.push({
325
+ contentType: uid,
326
+ field,
327
+ documentId: entry.documentId,
328
+ id: entry.id,
329
+ locale: entry.locale ?? null,
330
+ status
331
+ });
332
+ }
333
+ }
334
+ if (entries.length < pageSize) break;
335
+ }
336
+ }
337
+ }
338
+ return usage;
339
+ }
340
+ });
341
+ const services = {
342
+ service
343
+ };
344
+ const index = {
345
+ register,
346
+ bootstrap,
347
+ destroy,
348
+ config,
349
+ controllers,
350
+ routes,
351
+ services,
352
+ contentTypes,
353
+ policies,
354
+ middlewares
355
+ };
356
+ exports.default = index;
@@ -0,0 +1,356 @@
1
+ import { request as request$1 } from "node:http";
2
+ import { request } from "node:https";
3
+ const bootstrap = async ({ strapi }) => {
4
+ const url = await strapi.plugin("kontainer").service("service").getUrl();
5
+ if (!url) return;
6
+ try {
7
+ const host = new URL(url).host;
8
+ const fileUrl = strapi.plugin("kontainer").config("url", "");
9
+ const fileHost = fileUrl ? new URL(fileUrl).host : "";
10
+ const covered = host === "kontainer.com" || host.endsWith(".kontainer.com") || host === fileHost;
11
+ if (!covered) {
12
+ strapi.log.warn(
13
+ `[kontainer] ${host} is not covered by the plugin's Content-Security-Policy patch. Thumbnails may be blocked in the admin. Set KONTAINER_URL (config/plugins) to ${url} or add ${host} to img-src in config/middlewares.`
14
+ );
15
+ }
16
+ } catch {
17
+ }
18
+ };
19
+ const destroy = ({ strapi }) => {
20
+ };
21
+ const KONTAINER_CSP_SOURCES = ["https://*.kontainer.com", "https://kontainer.com"];
22
+ const patchContentSecurityPolicy = (strapi) => {
23
+ const sources = [...KONTAINER_CSP_SOURCES];
24
+ const fileUrl = strapi.plugin("kontainer").config("url", "");
25
+ if (fileUrl) {
26
+ try {
27
+ const u = new URL(fileUrl);
28
+ sources.push(`${u.protocol}//${u.host}`);
29
+ } catch {
30
+ }
31
+ }
32
+ const middlewares2 = strapi.config.get("middlewares");
33
+ const patched = middlewares2.map((mw) => {
34
+ const isPlain = mw === "strapi::security";
35
+ const entry = mw;
36
+ if (!isPlain && entry?.name !== "strapi::security") return mw;
37
+ const config2 = isPlain ? {} : entry.config ?? {};
38
+ const csp = config2.contentSecurityPolicy ?? {};
39
+ const directives = csp.directives ?? {};
40
+ const baseImg = ["'self'", "data:", "blob:", "https://market-assets.strapi.io"];
41
+ const baseMedia = ["'self'", "data:", "blob:"];
42
+ const merge = (existing, base) => [
43
+ .../* @__PURE__ */ new Set([...existing ?? base, ...sources])
44
+ ];
45
+ return {
46
+ name: "strapi::security",
47
+ config: {
48
+ ...config2,
49
+ contentSecurityPolicy: {
50
+ ...csp,
51
+ useDefaults: csp.useDefaults ?? true,
52
+ directives: {
53
+ ...directives,
54
+ "img-src": merge(directives["img-src"], baseImg),
55
+ "media-src": merge(directives["media-src"], baseMedia)
56
+ }
57
+ }
58
+ }
59
+ };
60
+ });
61
+ strapi.config.set("middlewares", patched);
62
+ };
63
+ const register = ({ strapi }) => {
64
+ strapi.customFields.register({
65
+ name: "media",
66
+ plugin: "kontainer",
67
+ type: "json"
68
+ });
69
+ patchContentSecurityPolicy(strapi);
70
+ };
71
+ const config = {
72
+ default: {
73
+ // Kontainer tenant URL the picker opens, e.g. https://yourcompany.kontainer.com
74
+ url: ""
75
+ },
76
+ validator(config2) {
77
+ if (config2.url && typeof config2.url !== "string") {
78
+ throw new Error("kontainer plugin: `url` must be a string");
79
+ }
80
+ }
81
+ };
82
+ const contentTypes = {};
83
+ const isValidUrl = (value) => {
84
+ try {
85
+ const u = new URL(value);
86
+ return u.protocol === "https:" || u.protocol === "http:";
87
+ } catch {
88
+ return false;
89
+ }
90
+ };
91
+ const controller = ({ strapi }) => ({
92
+ // Which entries reference a given Kontainer file id.
93
+ async usage(ctx) {
94
+ const { fileId } = ctx.params;
95
+ if (!fileId) {
96
+ return ctx.badRequest("fileId is required");
97
+ }
98
+ const data = await strapi.plugin("kontainer").service("service").findUsage(String(fileId));
99
+ ctx.body = { fileId: String(fileId), count: data.length, data };
100
+ },
101
+ // Effective picker URL for the admin input component.
102
+ async config(ctx) {
103
+ ctx.body = { url: await strapi.plugin("kontainer").service("service").getUrl() };
104
+ },
105
+ async getSettings(ctx) {
106
+ ctx.body = await strapi.plugin("kontainer").service("service").getSettings();
107
+ },
108
+ async validateSettings(ctx) {
109
+ const url = String(ctx.query.url ?? "");
110
+ if (!url) {
111
+ return ctx.badRequest("url query parameter is required");
112
+ }
113
+ ctx.body = await strapi.plugin("kontainer").service("service").validateUrl(url);
114
+ },
115
+ async updateSettings(ctx) {
116
+ const { url } = ctx.request.body ?? {};
117
+ if (typeof url !== "string" || url !== "" && !isValidUrl(url)) {
118
+ return ctx.badRequest("url must be empty or a valid http(s) URL");
119
+ }
120
+ await strapi.plugin("kontainer").service("service").setSettings({ url: url.replace(/\/+$/, "") });
121
+ ctx.body = await strapi.plugin("kontainer").service("service").getSettings();
122
+ }
123
+ });
124
+ const controllers = {
125
+ controller
126
+ };
127
+ const middlewares = {};
128
+ const policies = {};
129
+ const contentAPIRoutes = () => ({
130
+ type: "content-api",
131
+ routes: [
132
+ {
133
+ method: "GET",
134
+ path: "/usage/:fileId",
135
+ handler: "controller.usage",
136
+ config: {
137
+ policies: []
138
+ }
139
+ }
140
+ ]
141
+ });
142
+ const adminAPIRoutes = () => ({
143
+ type: "admin",
144
+ routes: [
145
+ {
146
+ method: "GET",
147
+ path: "/config",
148
+ handler: "controller.config",
149
+ config: {
150
+ policies: ["admin::isAuthenticatedAdmin"]
151
+ }
152
+ },
153
+ {
154
+ method: "GET",
155
+ path: "/settings",
156
+ handler: "controller.getSettings",
157
+ config: {
158
+ policies: ["admin::isAuthenticatedAdmin"]
159
+ }
160
+ },
161
+ {
162
+ method: "PUT",
163
+ path: "/settings",
164
+ handler: "controller.updateSettings",
165
+ config: {
166
+ policies: ["admin::isAuthenticatedAdmin"]
167
+ }
168
+ },
169
+ {
170
+ method: "GET",
171
+ path: "/settings/validate",
172
+ handler: "controller.validateSettings",
173
+ config: {
174
+ policies: ["admin::isAuthenticatedAdmin"]
175
+ }
176
+ }
177
+ ]
178
+ });
179
+ const routes = {
180
+ "content-api": contentAPIRoutes,
181
+ admin: adminAPIRoutes
182
+ };
183
+ const CUSTOM_FIELD = "plugin::kontainer.media";
184
+ const fetchLocationHeader = (probeUrl) => new Promise((resolve, reject) => {
185
+ const request$2 = probeUrl.protocol === "https:" ? request : request$1;
186
+ const req = request$2(
187
+ probeUrl,
188
+ {
189
+ method: "GET",
190
+ timeout: 5e3,
191
+ // local dev instances use self-signed certificates
192
+ rejectUnauthorized: process.env.NODE_ENV === "production"
193
+ },
194
+ (res) => {
195
+ res.destroy();
196
+ resolve(res.headers.location);
197
+ }
198
+ );
199
+ req.on("timeout", () => req.destroy(new Error("timeout")));
200
+ req.on("error", reject);
201
+ req.end();
202
+ });
203
+ const referencesFile = (value, fileId) => {
204
+ if (Array.isArray(value)) {
205
+ return value.some((v) => referencesFile(v, fileId));
206
+ }
207
+ if (value && typeof value === "object") {
208
+ const obj = value;
209
+ if (obj.fileId !== void 0 && String(obj.fileId) === fileId) return true;
210
+ return Object.values(obj).some((v) => referencesFile(v, fileId));
211
+ }
212
+ return false;
213
+ };
214
+ const service = ({ strapi }) => ({
215
+ settingsStore() {
216
+ return strapi.store({ type: "plugin", name: "kontainer" });
217
+ },
218
+ // Admin-panel setting wins; config/plugins (env) is the fallback.
219
+ async getUrl() {
220
+ const stored = await this.settingsStore().get({ key: "settings" });
221
+ const url = stored?.url || strapi.plugin("kontainer").config("url", "");
222
+ return url.replace(/\/+$/, "");
223
+ },
224
+ async getSettings() {
225
+ const stored = await this.settingsStore().get({ key: "settings" });
226
+ return {
227
+ url: stored?.url ?? "",
228
+ fileUrl: strapi.plugin("kontainer").config("url", "")
229
+ };
230
+ },
231
+ async setSettings(settings) {
232
+ await this.settingsStore().set({ key: "settings", value: settings });
233
+ },
234
+ // Is the URL an actual Kontainer instance? The picker entry point
235
+ // (/?cmsMode=1) redirects to ?cmsContextId=<uuid> — a stable fingerprint
236
+ // that works unauthenticated.
237
+ async validateUrl(raw) {
238
+ let url;
239
+ try {
240
+ url = new URL(raw);
241
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
242
+ throw new Error("unsupported protocol");
243
+ }
244
+ } catch {
245
+ return { valid: false, reason: "invalid-url" };
246
+ }
247
+ try {
248
+ const location = await fetchLocationHeader(
249
+ new URL(`${url.protocol}//${url.host}/?cmsMode=1`)
250
+ );
251
+ return location?.includes("cmsContextId=") ? { valid: true } : { valid: false, reason: "not-kontainer" };
252
+ } catch {
253
+ return { valid: false, reason: "unreachable" };
254
+ }
255
+ },
256
+ // Does this component (or any component nested in it) use the custom field?
257
+ componentHasKontainerField(uid, seen = /* @__PURE__ */ new Set()) {
258
+ if (seen.has(uid)) return false;
259
+ seen.add(uid);
260
+ const attributes = strapi.components[uid]?.attributes ?? {};
261
+ return Object.values(attributes).some((attr) => {
262
+ if (attr.customField === CUSTOM_FIELD) return true;
263
+ if (attr.type === "component") {
264
+ return this.componentHasKontainerField(attr.component, seen);
265
+ }
266
+ if (attr.type === "dynamiczone") {
267
+ return attr.components.some(
268
+ (c) => this.componentHasKontainerField(c, seen)
269
+ );
270
+ }
271
+ return false;
272
+ });
273
+ },
274
+ // Populate spec covering every path to a kontainer field inside a component.
275
+ populateForComponent(uid) {
276
+ const attributes = strapi.components[uid]?.attributes ?? {};
277
+ const inner = this.populateForAttributes(attributes);
278
+ return Object.keys(inner).length ? { populate: inner } : true;
279
+ },
280
+ populateForAttributes(attributes) {
281
+ const populate = {};
282
+ for (const [name, attr] of Object.entries(attributes)) {
283
+ if (attr.type === "component" && this.componentHasKontainerField(attr.component)) {
284
+ populate[name] = this.populateForComponent(attr.component);
285
+ } else if (attr.type === "dynamiczone") {
286
+ const withField = attr.components.filter(
287
+ (c) => this.componentHasKontainerField(c)
288
+ );
289
+ if (withField.length) {
290
+ populate[name] = {
291
+ on: Object.fromEntries(
292
+ withField.map((c) => [c, this.populateForComponent(c)])
293
+ )
294
+ };
295
+ }
296
+ }
297
+ }
298
+ return populate;
299
+ },
300
+ async findUsage(fileId) {
301
+ const usage = [];
302
+ for (const [uid, contentType] of Object.entries(strapi.contentTypes)) {
303
+ if (!uid.startsWith("api::")) continue;
304
+ const attributes = contentType.attributes;
305
+ const directFields = Object.entries(attributes).filter(([, attr]) => attr.customField === CUSTOM_FIELD).map(([name]) => name);
306
+ const populate = this.populateForAttributes(attributes);
307
+ const candidateFields = [...directFields, ...Object.keys(populate)];
308
+ if (!candidateFields.length) continue;
309
+ for (const status of ["draft", "published"]) {
310
+ const pageSize = 500;
311
+ for (let start = 0; ; start += pageSize) {
312
+ const entries = await strapi.documents(uid).findMany({
313
+ status,
314
+ populate,
315
+ locale: "*",
316
+ start,
317
+ limit: pageSize
318
+ });
319
+ for (const entry of entries) {
320
+ for (const field of candidateFields) {
321
+ if (!referencesFile(entry[field], fileId)) continue;
322
+ usage.push({
323
+ contentType: uid,
324
+ field,
325
+ documentId: entry.documentId,
326
+ id: entry.id,
327
+ locale: entry.locale ?? null,
328
+ status
329
+ });
330
+ }
331
+ }
332
+ if (entries.length < pageSize) break;
333
+ }
334
+ }
335
+ }
336
+ return usage;
337
+ }
338
+ });
339
+ const services = {
340
+ service
341
+ };
342
+ const index = {
343
+ register,
344
+ bootstrap,
345
+ destroy,
346
+ config,
347
+ controllers,
348
+ routes,
349
+ services,
350
+ contentTypes,
351
+ policies,
352
+ middlewares
353
+ };
354
+ export {
355
+ index as default
356
+ };