@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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/admin/KontainerMediaInput-C6wPHxqb.js +148 -0
- package/dist/admin/KontainerMediaInput-DmEdEpue.mjs +130 -0
- package/dist/admin/Settings-fZTH8z6T.js +152 -0
- package/dist/admin/Settings-tNEBWX1o.mjs +134 -0
- package/dist/admin/da-Be8uFPrt.js +28 -0
- package/dist/admin/da-WHaTm2Cy.mjs +28 -0
- package/dist/admin/en-B3JWZj1Z.mjs +28 -0
- package/dist/admin/en-B7EZhOY4.js +28 -0
- package/dist/admin/index-CMAfPTry.js +121 -0
- package/dist/admin/index-hpPTiq45.mjs +122 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/src/components/Initializer.d.ts +5 -0
- package/dist/admin/src/components/KontainerMediaInput.d.ts +3 -0
- package/dist/admin/src/components/PluginIcon.d.ts +2 -0
- package/dist/admin/src/index.d.ts +3 -0
- package/dist/admin/src/pages/Settings.d.ts +3 -0
- package/dist/admin/src/pluginId.d.ts +1 -0
- package/dist/admin/src/utils/getTranslation.d.ts +2 -0
- package/dist/server/index.js +356 -0
- package/dist/server/index.mjs +356 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/config/index.d.ts +9 -0
- package/dist/server/src/content-types/index.d.ts +2 -0
- package/dist/server/src/controllers/controller.d.ts +11 -0
- package/dist/server/src/controllers/index.d.ts +12 -0
- package/dist/server/src/destroy.d.ts +5 -0
- package/dist/server/src/index.d.ts +108 -0
- package/dist/server/src/middlewares/index.d.ts +2 -0
- package/dist/server/src/policies/index.d.ts +2 -0
- package/dist/server/src/register.d.ts +5 -0
- package/dist/server/src/routes/admin/index.d.ts +12 -0
- package/dist/server/src/routes/content-api/index.d.ts +12 -0
- package/dist/server/src/routes/index.d.ts +25 -0
- package/dist/server/src/services/index.d.ts +51 -0
- package/dist/server/src/services/service.d.ts +57 -0
- 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
|
+
};
|