strapi-content-sync-pro 1.0.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/admin/src/components/ConfigTab.jsx +1038 -0
  4. package/admin/src/components/ContentTypesTab.jsx +160 -0
  5. package/admin/src/components/HelpTab.jsx +945 -0
  6. package/admin/src/components/LogsTab.jsx +136 -0
  7. package/admin/src/components/MediaTab.jsx +557 -0
  8. package/admin/src/components/SyncProfilesTab.jsx +715 -0
  9. package/admin/src/components/SyncTab.jsx +988 -0
  10. package/admin/src/index.js +31 -0
  11. package/admin/src/pages/App/index.jsx +129 -0
  12. package/admin/src/pluginId.js +3 -0
  13. package/package.json +84 -0
  14. package/server/src/bootstrap.js +151 -0
  15. package/server/src/config/index.js +5 -0
  16. package/server/src/content-types/index.js +7 -0
  17. package/server/src/content-types/sync-log/schema.json +24 -0
  18. package/server/src/controllers/alerts.js +59 -0
  19. package/server/src/controllers/config.js +292 -0
  20. package/server/src/controllers/content-type-discovery.js +9 -0
  21. package/server/src/controllers/dependencies.js +109 -0
  22. package/server/src/controllers/index.js +29 -0
  23. package/server/src/controllers/ping.js +7 -0
  24. package/server/src/controllers/sync-config.js +26 -0
  25. package/server/src/controllers/sync-enforcement.js +323 -0
  26. package/server/src/controllers/sync-execution.js +134 -0
  27. package/server/src/controllers/sync-log.js +18 -0
  28. package/server/src/controllers/sync-media.js +158 -0
  29. package/server/src/controllers/sync-profiles.js +182 -0
  30. package/server/src/controllers/sync.js +31 -0
  31. package/server/src/destroy.js +7 -0
  32. package/server/src/index.js +21 -0
  33. package/server/src/middlewares/verify-signature.js +32 -0
  34. package/server/src/register.js +7 -0
  35. package/server/src/routes/index.js +111 -0
  36. package/server/src/services/alerts.js +437 -0
  37. package/server/src/services/config.js +68 -0
  38. package/server/src/services/content-type-discovery.js +41 -0
  39. package/server/src/services/dependency-resolver.js +284 -0
  40. package/server/src/services/index.js +30 -0
  41. package/server/src/services/ping.js +7 -0
  42. package/server/src/services/sync-config.js +45 -0
  43. package/server/src/services/sync-enforcement.js +362 -0
  44. package/server/src/services/sync-execution.js +541 -0
  45. package/server/src/services/sync-log.js +56 -0
  46. package/server/src/services/sync-media.js +963 -0
  47. package/server/src/services/sync-profiles.js +380 -0
  48. package/server/src/services/sync.js +248 -0
  49. package/server/src/utils/applier.js +89 -0
  50. package/server/src/utils/comparator.js +83 -0
  51. package/server/src/utils/fetcher.js +142 -0
  52. package/server/src/utils/hmac.js +37 -0
  53. package/server/src/utils/pagination.js +51 -0
  54. package/server/src/utils/sync-guard.js +29 -0
  55. package/server/src/utils/sync-id.js +16 -0
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ const PLUGIN_ID = 'strapi-content-sync-pro';
4
+
5
+ module.exports = {
6
+ async get(ctx) {
7
+ const configService = strapi.plugin(PLUGIN_ID).service('config');
8
+ const config = await configService.getConfig({ safe: true });
9
+
10
+ ctx.body = { data: config };
11
+ },
12
+
13
+ /**
14
+ * GET /config/test
15
+ * Test connectivity to the remote server using stored credentials
16
+ */
17
+ async test(ctx) {
18
+ const configService = strapi.plugin(PLUGIN_ID).service('config');
19
+ const config = await configService.getConfig({ safe: false });
20
+
21
+ if (!config || !config.baseUrl) {
22
+ return ctx.body = {
23
+ data: {
24
+ success: false,
25
+ stage: 'config',
26
+ message: 'Remote server URL is not configured',
27
+ },
28
+ };
29
+ }
30
+
31
+ if (!config.apiToken) {
32
+ return ctx.body = {
33
+ data: {
34
+ success: false,
35
+ stage: 'config',
36
+ message: 'API token is not configured',
37
+ },
38
+ };
39
+ }
40
+
41
+ // Step 1: Basic reachability via public ping endpoint
42
+ const startTime = Date.now();
43
+ let pingStatus = null;
44
+ try {
45
+ const pingRes = await fetch(`${config.baseUrl}/api/${PLUGIN_ID}/ping`, {
46
+ method: 'GET',
47
+ });
48
+ pingStatus = pingRes.status;
49
+ if (!pingRes.ok) {
50
+ return ctx.body = {
51
+ data: {
52
+ success: false,
53
+ stage: 'ping',
54
+ message: `Remote server returned ${pingRes.status} on /ping. The plugin may not be installed on the remote server.`,
55
+ latency: Date.now() - startTime,
56
+ },
57
+ };
58
+ }
59
+ } catch (err) {
60
+ return ctx.body = {
61
+ data: {
62
+ success: false,
63
+ stage: 'network',
64
+ message: `Cannot reach remote server: ${err.message}`,
65
+ latency: Date.now() - startTime,
66
+ },
67
+ };
68
+ }
69
+
70
+ const pingLatency = Date.now() - startTime;
71
+
72
+ // Step 2: Verify API token works against an authenticated endpoint
73
+ let authWorks = false;
74
+ let remoteInfo = null;
75
+ try {
76
+ const infoRes = await fetch(`${config.baseUrl}/api/${PLUGIN_ID}/enforcement/local-info`, {
77
+ method: 'GET',
78
+ headers: { Authorization: `Bearer ${config.apiToken}` },
79
+ });
80
+ if (infoRes.ok) {
81
+ authWorks = true;
82
+ const body = await infoRes.json().catch(() => ({}));
83
+ remoteInfo = body?.data || null;
84
+ } else if (infoRes.status === 401 || infoRes.status === 403) {
85
+ return ctx.body = {
86
+ data: {
87
+ success: false,
88
+ stage: 'auth',
89
+ message: `API token rejected by remote server (${infoRes.status}). Verify the token is valid and has Full Access.`,
90
+ latency: pingLatency,
91
+ },
92
+ };
93
+ } else if (infoRes.status === 404) {
94
+ return ctx.body = {
95
+ data: {
96
+ success: false,
97
+ stage: 'plugin',
98
+ message: 'Remote server reachable but /enforcement/local-info not found. Ensure the plugin is installed and updated on the remote server.',
99
+ latency: pingLatency,
100
+ },
101
+ };
102
+ }
103
+ } catch (err) {
104
+ return ctx.body = {
105
+ data: {
106
+ success: false,
107
+ stage: 'auth',
108
+ message: `Error validating API token: ${err.message}`,
109
+ latency: pingLatency,
110
+ },
111
+ };
112
+ }
113
+
114
+ ctx.body = {
115
+ data: {
116
+ success: true,
117
+ stage: 'complete',
118
+ message: authWorks
119
+ ? 'Connection successful and API token validated'
120
+ : 'Reachable but API token could not be validated',
121
+ latency: pingLatency,
122
+ remoteInfo,
123
+ },
124
+ };
125
+ },
126
+
127
+ async set(ctx) {
128
+ const { body } = ctx.request;
129
+
130
+ if (!body || typeof body !== 'object') {
131
+ return ctx.badRequest('Request body must be a JSON object');
132
+ }
133
+
134
+ const configService = strapi.plugin(PLUGIN_ID).service('config');
135
+
136
+ try {
137
+ const saved = await configService.setConfig(body);
138
+
139
+ const sanitized = { ...saved };
140
+ if (sanitized.apiToken) {
141
+ sanitized.apiToken = '••••••••';
142
+ }
143
+ if (sanitized.sharedSecret) {
144
+ sanitized.sharedSecret = '••••••••';
145
+ }
146
+
147
+ ctx.body = { data: sanitized };
148
+ } catch (err) {
149
+ return ctx.badRequest(err.message);
150
+ }
151
+ },
152
+
153
+ /**
154
+ * POST /config/remote-login
155
+ * Proxy login to remote Strapi and retrieve/create API token
156
+ */
157
+ async remoteLogin(ctx) {
158
+ const { baseUrl, email, password } = ctx.request.body;
159
+
160
+ if (!baseUrl || !email || !password) {
161
+ return ctx.badRequest('baseUrl, email, and password are required');
162
+ }
163
+
164
+ try {
165
+ // Step 1: Login to remote Strapi admin
166
+ const loginResponse = await fetch(`${baseUrl}/admin/login`, {
167
+ method: 'POST',
168
+ headers: {
169
+ 'Content-Type': 'application/json',
170
+ },
171
+ body: JSON.stringify({
172
+ email,
173
+ password,
174
+ }),
175
+ });
176
+
177
+ if (!loginResponse.ok) {
178
+ const errorBody = await loginResponse.json().catch(() => ({}));
179
+ const errorMessage = errorBody?.error?.message || `Login failed with status ${loginResponse.status}`;
180
+ return ctx.throw(loginResponse.status, errorMessage);
181
+ }
182
+
183
+ const loginData = await loginResponse.json();
184
+ const adminJwt = loginData.data?.token;
185
+
186
+ if (!adminJwt) {
187
+ return ctx.throw(500, 'No token received from remote server');
188
+ }
189
+
190
+ // Step 2: Check for existing token or create new one
191
+ let apiToken = null;
192
+ const TOKEN_NAME = 'strapi-data-sync-plugin'; // Fixed name for easy identification
193
+
194
+ // First, list existing API tokens to see if one exists for the plugin
195
+ const listTokensResponse = await fetch(`${baseUrl}/admin/api-tokens`, {
196
+ method: 'GET',
197
+ headers: {
198
+ Authorization: `Bearer ${adminJwt}`,
199
+ },
200
+ });
201
+
202
+ let existingTokenId = null;
203
+ if (listTokensResponse.ok) {
204
+ const tokensData = await listTokensResponse.json();
205
+ const existingToken = tokensData.data?.find(t =>
206
+ t.name === TOKEN_NAME ||
207
+ t.name?.startsWith('strapi-data-sync') ||
208
+ t.name?.startsWith('data-sync-auto')
209
+ );
210
+
211
+ if (existingToken) {
212
+ existingTokenId = existingToken.id;
213
+ // Delete the old token so we can create a fresh one with the access key
214
+ await fetch(`${baseUrl}/admin/api-tokens/${existingTokenId}`, {
215
+ method: 'DELETE',
216
+ headers: {
217
+ Authorization: `Bearer ${adminJwt}`,
218
+ },
219
+ });
220
+ }
221
+ }
222
+
223
+ // Step 3: Create a new API token with full access
224
+ const createTokenResponse = await fetch(`${baseUrl}/admin/api-tokens`, {
225
+ method: 'POST',
226
+ headers: {
227
+ 'Content-Type': 'application/json',
228
+ Authorization: `Bearer ${adminJwt}`,
229
+ },
230
+ body: JSON.stringify({
231
+ name: TOKEN_NAME,
232
+ description: 'Auto-generated token for Strapi-to-Strapi Data Sync plugin',
233
+ type: 'full-access',
234
+ lifespan: null, // No expiration
235
+ }),
236
+ });
237
+
238
+ if (!createTokenResponse.ok) {
239
+ const errorBody = await createTokenResponse.json().catch(() => ({}));
240
+ const errorMessage = errorBody?.error?.message || 'Failed to create API token';
241
+ return ctx.throw(createTokenResponse.status, errorMessage);
242
+ }
243
+
244
+ const tokenData = await createTokenResponse.json();
245
+ apiToken = tokenData.data?.accessKey;
246
+
247
+ if (!apiToken) {
248
+ return ctx.throw(500, 'Failed to retrieve API token from created token');
249
+ }
250
+
251
+ // Step 4: Optionally get remote instance ID if plugin is installed
252
+ let remoteInstanceId = null;
253
+ try {
254
+ const remoteConfigResponse = await fetch(`${baseUrl}/api/${PLUGIN_ID}/config`, {
255
+ method: 'GET',
256
+ headers: {
257
+ Authorization: `Bearer ${apiToken}`,
258
+ },
259
+ });
260
+
261
+ if (remoteConfigResponse.ok) {
262
+ const remoteConfig = await remoteConfigResponse.json();
263
+ remoteInstanceId = remoteConfig.data?.instanceId;
264
+ }
265
+ } catch {
266
+ // Remote plugin might not be installed or configured
267
+ }
268
+
269
+ // Step 5: Save the token to local config
270
+ const configService = strapi.plugin(PLUGIN_ID).service('config');
271
+ await configService.setConfig({
272
+ baseUrl,
273
+ apiToken,
274
+ });
275
+
276
+ ctx.body = {
277
+ data: {
278
+ success: true,
279
+ apiToken: '••••••••', // Don't send actual token back to frontend
280
+ tokenName: TOKEN_NAME,
281
+ instanceId: remoteInstanceId,
282
+ message: 'Successfully authenticated and created API token',
283
+ },
284
+ };
285
+ } catch (err) {
286
+ if (err.status) {
287
+ throw err; // Re-throw Koa errors
288
+ }
289
+ ctx.throw(500, err.message || 'Remote login failed');
290
+ }
291
+ },
292
+ };
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ async find(ctx) {
5
+ const service = strapi.plugin('strapi-content-sync-pro').service('contentTypeDiscovery');
6
+ const contentTypes = service.getSyncableContentTypes();
7
+ ctx.body = { data: contentTypes };
8
+ },
9
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const PLUGIN_ID = 'strapi-content-sync-pro';
4
+
5
+ module.exports = ({ strapi }) => ({
6
+ /**
7
+ * GET /dependencies/all
8
+ * Get dependency analysis for all enabled content types
9
+ */
10
+ async analyzeAll(ctx) {
11
+ try {
12
+ const dependencyResolver = strapi.plugin(PLUGIN_ID).service('dependencyResolver');
13
+ const syncConfig = strapi.plugin(PLUGIN_ID).service('syncConfig');
14
+
15
+ const config = await syncConfig.getSyncConfig();
16
+ const enabledTypes = (config.contentTypes || [])
17
+ .filter((ct) => ct.enabled)
18
+ .map((ct) => ct.uid);
19
+
20
+ const allDependencies = {};
21
+
22
+ for (const uid of enabledTypes) {
23
+ try {
24
+ const analysis = dependencyResolver.analyzeContentType(uid);
25
+ allDependencies[uid] = analysis.relations || [];
26
+ } catch (err) {
27
+ // Skip if content type doesn't exist
28
+ allDependencies[uid] = [];
29
+ }
30
+ }
31
+
32
+ ctx.body = { data: allDependencies };
33
+ } catch (err) {
34
+ ctx.throw(500, err.message);
35
+ }
36
+ },
37
+
38
+ /**
39
+ * GET /dependencies/:uid
40
+ * Get dependency analysis for a content type
41
+ */
42
+ async analyze(ctx) {
43
+ const { uid } = ctx.params;
44
+ try {
45
+ const analysis = strapi.plugin(PLUGIN_ID).service('dependencyResolver').analyzeContentType(uid);
46
+ ctx.body = { data: analysis };
47
+ } catch (err) {
48
+ ctx.throw(400, err.message);
49
+ }
50
+ },
51
+
52
+ /**
53
+ * GET /dependencies/:uid/graph
54
+ * Get dependency graph for a content type
55
+ */
56
+ async getGraph(ctx) {
57
+ const { uid } = ctx.params;
58
+ const depth = parseInt(ctx.query.depth, 10) || 1;
59
+ try {
60
+ const graph = strapi.plugin(PLUGIN_ID).service('dependencyResolver').buildDependencyGraph(uid, depth);
61
+ ctx.body = { data: graph };
62
+ } catch (err) {
63
+ ctx.throw(400, err.message);
64
+ }
65
+ },
66
+
67
+ /**
68
+ * GET /dependencies/:uid/sync-order
69
+ * Get sync order for a content type
70
+ */
71
+ async getSyncOrder(ctx) {
72
+ const { uid } = ctx.params;
73
+ const depth = parseInt(ctx.query.depth, 10) || 1;
74
+ try {
75
+ const order = strapi.plugin(PLUGIN_ID).service('dependencyResolver').getSyncOrder(uid, depth);
76
+ ctx.body = { data: order };
77
+ } catch (err) {
78
+ ctx.throw(400, err.message);
79
+ }
80
+ },
81
+
82
+ /**
83
+ * GET /dependencies/:uid/summary
84
+ * Get dependency summary for UI
85
+ */
86
+ async getSummary(ctx) {
87
+ const { uid } = ctx.params;
88
+ const depth = parseInt(ctx.query.depth, 10) || 1;
89
+ try {
90
+ const summary = strapi.plugin(PLUGIN_ID).service('dependencyResolver').getDependencySummary(uid, depth);
91
+ ctx.body = { data: summary };
92
+ } catch (err) {
93
+ ctx.throw(400, err.message);
94
+ }
95
+ },
96
+
97
+ /**
98
+ * POST /dependencies/clear-cache
99
+ * Clear dependency cache
100
+ */
101
+ async clearCache(ctx) {
102
+ try {
103
+ strapi.plugin(PLUGIN_ID).service('dependencyResolver').clearCache();
104
+ ctx.body = { data: { success: true } };
105
+ } catch (err) {
106
+ ctx.throw(500, err.message);
107
+ }
108
+ },
109
+ });
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const ping = require('./ping');
4
+ const config = require('./config');
5
+ const contentTypeDiscovery = require('./content-type-discovery');
6
+ const syncConfig = require('./sync-config');
7
+ const sync = require('./sync');
8
+ const syncLog = require('./sync-log');
9
+ const syncProfiles = require('./sync-profiles');
10
+ const syncExecution = require('./sync-execution');
11
+ const syncEnforcement = require('./sync-enforcement');
12
+ const syncMedia = require('./sync-media');
13
+ const alerts = require('./alerts');
14
+ const dependencies = require('./dependencies');
15
+
16
+ module.exports = {
17
+ ping,
18
+ config,
19
+ contentTypeDiscovery,
20
+ syncConfig,
21
+ sync,
22
+ syncLog,
23
+ syncProfiles,
24
+ syncExecution,
25
+ syncEnforcement,
26
+ syncMedia,
27
+ alerts,
28
+ dependencies,
29
+ };
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ async index(ctx) {
5
+ ctx.body = { status: 'ok' };
6
+ },
7
+ };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ async get(ctx) {
5
+ const service = strapi.plugin('strapi-content-sync-pro').service('syncConfig');
6
+ const config = await service.getSyncConfig();
7
+ ctx.body = { data: config };
8
+ },
9
+
10
+ async set(ctx) {
11
+ const { body } = ctx.request;
12
+
13
+ if (!body || typeof body !== 'object') {
14
+ return ctx.badRequest('Request body must be a JSON object');
15
+ }
16
+
17
+ const service = strapi.plugin('strapi-content-sync-pro').service('syncConfig');
18
+
19
+ try {
20
+ const saved = await service.setSyncConfig(body);
21
+ ctx.body = { data: saved };
22
+ } catch (err) {
23
+ return ctx.badRequest(err.message);
24
+ }
25
+ },
26
+ };