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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/admin/src/components/ConfigTab.jsx +1038 -0
- package/admin/src/components/ContentTypesTab.jsx +160 -0
- package/admin/src/components/HelpTab.jsx +945 -0
- package/admin/src/components/LogsTab.jsx +136 -0
- package/admin/src/components/MediaTab.jsx +557 -0
- package/admin/src/components/SyncProfilesTab.jsx +715 -0
- package/admin/src/components/SyncTab.jsx +988 -0
- package/admin/src/index.js +31 -0
- package/admin/src/pages/App/index.jsx +129 -0
- package/admin/src/pluginId.js +3 -0
- package/package.json +84 -0
- package/server/src/bootstrap.js +151 -0
- package/server/src/config/index.js +5 -0
- package/server/src/content-types/index.js +7 -0
- package/server/src/content-types/sync-log/schema.json +24 -0
- package/server/src/controllers/alerts.js +59 -0
- package/server/src/controllers/config.js +292 -0
- package/server/src/controllers/content-type-discovery.js +9 -0
- package/server/src/controllers/dependencies.js +109 -0
- package/server/src/controllers/index.js +29 -0
- package/server/src/controllers/ping.js +7 -0
- package/server/src/controllers/sync-config.js +26 -0
- package/server/src/controllers/sync-enforcement.js +323 -0
- package/server/src/controllers/sync-execution.js +134 -0
- package/server/src/controllers/sync-log.js +18 -0
- package/server/src/controllers/sync-media.js +158 -0
- package/server/src/controllers/sync-profiles.js +182 -0
- package/server/src/controllers/sync.js +31 -0
- package/server/src/destroy.js +7 -0
- package/server/src/index.js +21 -0
- package/server/src/middlewares/verify-signature.js +32 -0
- package/server/src/register.js +7 -0
- package/server/src/routes/index.js +111 -0
- package/server/src/services/alerts.js +437 -0
- package/server/src/services/config.js +68 -0
- package/server/src/services/content-type-discovery.js +41 -0
- package/server/src/services/dependency-resolver.js +284 -0
- package/server/src/services/index.js +30 -0
- package/server/src/services/ping.js +7 -0
- package/server/src/services/sync-config.js +45 -0
- package/server/src/services/sync-enforcement.js +362 -0
- package/server/src/services/sync-execution.js +541 -0
- package/server/src/services/sync-log.js +56 -0
- package/server/src/services/sync-media.js +963 -0
- package/server/src/services/sync-profiles.js +380 -0
- package/server/src/services/sync.js +248 -0
- package/server/src/utils/applier.js +89 -0
- package/server/src/utils/comparator.js +83 -0
- package/server/src/utils/fetcher.js +142 -0
- package/server/src/utils/hmac.js +37 -0
- package/server/src/utils/pagination.js +51 -0
- package/server/src/utils/sync-guard.js +29 -0
- package/server/src/utils/sync-id.js +16 -0
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sync Media Service
|
|
5
|
+
*
|
|
6
|
+
* Profile-based media synchronization between two Strapi instances.
|
|
7
|
+
* Each media profile defines direction, strategy, conflict resolution,
|
|
8
|
+
* file-type filters, and execution settings — mirroring how content-type
|
|
9
|
+
* sync profiles work.
|
|
10
|
+
*
|
|
11
|
+
* Strategies:
|
|
12
|
+
* 1. url — HTTP upload/download via /api/upload
|
|
13
|
+
* 2. rsync — file-level copy via rsync binary
|
|
14
|
+
* 3. disabled — no media sync
|
|
15
|
+
*
|
|
16
|
+
* Sync scope:
|
|
17
|
+
* - DB rows : plugin::upload.file metadata (name, caption, alt, mime, …)
|
|
18
|
+
* - File bytes: actual media assets (via URL or rsync)
|
|
19
|
+
* - Both can be toggled independently per profile.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const fsp = require('node:fs/promises');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const { spawn } = require('node:child_process');
|
|
26
|
+
const { pipeline } = require('node:stream/promises');
|
|
27
|
+
const { Readable } = require('node:stream');
|
|
28
|
+
|
|
29
|
+
const PROFILES_KEY = 'media-sync-profiles';
|
|
30
|
+
const GLOBAL_SETTINGS_KEY = 'media-sync-global-settings';
|
|
31
|
+
const STATUS_KEY = 'media-sync-status';
|
|
32
|
+
const PLUGIN_NAME = 'strapi-content-sync-pro';
|
|
33
|
+
|
|
34
|
+
// ── Default MIME type groups ────────────────────────────────────────────────
|
|
35
|
+
const DEFAULT_MIME_IMAGES = ['image/'];
|
|
36
|
+
const DEFAULT_MIME_VIDEOS = [
|
|
37
|
+
'video/mp4', 'video/webm', 'video/x-msvideo', 'video/quicktime',
|
|
38
|
+
'video/x-matroska', 'video/ogg', 'video/3gpp',
|
|
39
|
+
];
|
|
40
|
+
const DEFAULT_MIME_DOCUMENTS = [
|
|
41
|
+
'application/pdf',
|
|
42
|
+
'application/msword',
|
|
43
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
44
|
+
'application/vnd.ms-excel',
|
|
45
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
46
|
+
'application/vnd.ms-powerpoint',
|
|
47
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
48
|
+
'application/vnd.oasis.opendocument.text',
|
|
49
|
+
'application/vnd.oasis.opendocument.spreadsheet',
|
|
50
|
+
'text/csv',
|
|
51
|
+
'text/plain',
|
|
52
|
+
];
|
|
53
|
+
const DEFAULT_MIME_ALL = [...DEFAULT_MIME_IMAGES, ...DEFAULT_MIME_VIDEOS, ...DEFAULT_MIME_DOCUMENTS];
|
|
54
|
+
|
|
55
|
+
const VALID_STRATEGIES = ['disabled', 'url', 'rsync'];
|
|
56
|
+
const VALID_DIRECTIONS = ['push', 'pull', 'both'];
|
|
57
|
+
const VALID_CONFLICT_STRATEGIES = ['latest_wins', 'local_wins', 'remote_wins'];
|
|
58
|
+
const VALID_EXECUTION_MODES = ['on_demand', 'scheduled', 'live'];
|
|
59
|
+
const VALID_SCHEDULE_TYPES = ['interval', 'timeout', 'cron', 'external'];
|
|
60
|
+
|
|
61
|
+
// ── Default global settings ─────────────────────────────────────────────────
|
|
62
|
+
const DEFAULT_GLOBAL_SETTINGS = {
|
|
63
|
+
pageSize: 50,
|
|
64
|
+
batchConcurrency: 2,
|
|
65
|
+
skipIfSameSize: true,
|
|
66
|
+
// rsync defaults
|
|
67
|
+
rsyncCommand: 'rsync',
|
|
68
|
+
rsyncArgs: '-avz --delete-after',
|
|
69
|
+
localMediaPath: '',
|
|
70
|
+
remoteMediaPath: '',
|
|
71
|
+
sshPort: 22,
|
|
72
|
+
sshIdentityFile: '',
|
|
73
|
+
rsyncTimeoutMs: 30 * 60 * 1000,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ── Default media profile template ──────────────────────────────────────────
|
|
77
|
+
function makeDefaultProfile(overrides = {}) {
|
|
78
|
+
return {
|
|
79
|
+
id: overrides.id || `media-profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
80
|
+
name: overrides.name || 'Media Sync',
|
|
81
|
+
strategy: overrides.strategy || 'url',
|
|
82
|
+
direction: overrides.direction || 'both',
|
|
83
|
+
conflictStrategy: overrides.conflictStrategy || 'latest_wins',
|
|
84
|
+
active: overrides.active !== undefined ? overrides.active : false,
|
|
85
|
+
// What to sync
|
|
86
|
+
syncDbRows: overrides.syncDbRows !== undefined ? overrides.syncDbRows : true,
|
|
87
|
+
syncFileBytes: overrides.syncFileBytes !== undefined ? overrides.syncFileBytes : true,
|
|
88
|
+
// File-type filters
|
|
89
|
+
includeMime: overrides.includeMime || [],
|
|
90
|
+
excludeMime: overrides.excludeMime || [],
|
|
91
|
+
includePatterns: overrides.includePatterns || [],
|
|
92
|
+
excludePatterns: overrides.excludePatterns || [],
|
|
93
|
+
dryRun: overrides.dryRun || false,
|
|
94
|
+
// Execution settings (per profile, like content sync)
|
|
95
|
+
executionMode: overrides.executionMode || 'on_demand',
|
|
96
|
+
scheduleType: overrides.scheduleType || 'interval',
|
|
97
|
+
scheduleInterval: overrides.scheduleInterval || 60,
|
|
98
|
+
cronExpression: overrides.cronExpression || '',
|
|
99
|
+
enabled: overrides.enabled !== undefined ? overrides.enabled : true,
|
|
100
|
+
lastExecutedAt: overrides.lastExecutedAt || null,
|
|
101
|
+
nextExecutionAt: overrides.nextExecutionAt || null,
|
|
102
|
+
createdAt: overrides.createdAt || new Date().toISOString(),
|
|
103
|
+
updatedAt: overrides.updatedAt || new Date().toISOString(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Auto-generated default profiles when media sync is first enabled
|
|
108
|
+
function generateDefaultProfiles() {
|
|
109
|
+
return [
|
|
110
|
+
makeDefaultProfile({
|
|
111
|
+
id: 'media-full-push',
|
|
112
|
+
name: 'Full Push (Media)',
|
|
113
|
+
strategy: 'url',
|
|
114
|
+
direction: 'push',
|
|
115
|
+
conflictStrategy: 'local_wins',
|
|
116
|
+
active: false,
|
|
117
|
+
includeMime: [...DEFAULT_MIME_ALL],
|
|
118
|
+
syncDbRows: true,
|
|
119
|
+
syncFileBytes: true,
|
|
120
|
+
}),
|
|
121
|
+
makeDefaultProfile({
|
|
122
|
+
id: 'media-full-pull',
|
|
123
|
+
name: 'Full Pull (Media)',
|
|
124
|
+
strategy: 'url',
|
|
125
|
+
direction: 'pull',
|
|
126
|
+
conflictStrategy: 'remote_wins',
|
|
127
|
+
active: false,
|
|
128
|
+
includeMime: [...DEFAULT_MIME_ALL],
|
|
129
|
+
syncDbRows: true,
|
|
130
|
+
syncFileBytes: true,
|
|
131
|
+
}),
|
|
132
|
+
makeDefaultProfile({
|
|
133
|
+
id: 'media-bidirectional',
|
|
134
|
+
name: 'Bidirectional (Media)',
|
|
135
|
+
strategy: 'url',
|
|
136
|
+
direction: 'both',
|
|
137
|
+
conflictStrategy: 'latest_wins',
|
|
138
|
+
active: true,
|
|
139
|
+
includeMime: [...DEFAULT_MIME_ALL],
|
|
140
|
+
syncDbRows: true,
|
|
141
|
+
syncFileBytes: true,
|
|
142
|
+
}),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = ({ strapi }) => {
|
|
147
|
+
const log = strapi.log;
|
|
148
|
+
const schedulerHandles = {};
|
|
149
|
+
|
|
150
|
+
function store() {
|
|
151
|
+
return strapi.store({ type: 'plugin', name: PLUGIN_NAME });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function plugin() {
|
|
155
|
+
return strapi.plugin(PLUGIN_NAME);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Profile CRUD
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
async function getProfiles() {
|
|
163
|
+
const data = await store().get({ key: PROFILES_KEY });
|
|
164
|
+
if (!data || !Array.isArray(data) || data.length === 0) {
|
|
165
|
+
// First time — generate defaults
|
|
166
|
+
const defaults = generateDefaultProfiles();
|
|
167
|
+
await store().set({ key: PROFILES_KEY, value: defaults });
|
|
168
|
+
return defaults;
|
|
169
|
+
}
|
|
170
|
+
return data;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function getProfile(profileId) {
|
|
174
|
+
const profiles = await getProfiles();
|
|
175
|
+
return profiles.find((p) => p.id === profileId) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function getActiveProfile() {
|
|
179
|
+
const profiles = await getProfiles();
|
|
180
|
+
return profiles.find((p) => p.active) || null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function createProfile(data) {
|
|
184
|
+
const profiles = await getProfiles();
|
|
185
|
+
const profile = makeDefaultProfile({ ...data, id: undefined });
|
|
186
|
+
profiles.push(profile);
|
|
187
|
+
await store().set({ key: PROFILES_KEY, value: profiles });
|
|
188
|
+
return profile;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function updateProfile(profileId, data) {
|
|
192
|
+
const profiles = await getProfiles();
|
|
193
|
+
const idx = profiles.findIndex((p) => p.id === profileId);
|
|
194
|
+
if (idx === -1) throw new Error(`Media profile "${profileId}" not found`);
|
|
195
|
+
const updated = { ...profiles[idx], ...data, id: profileId, updatedAt: new Date().toISOString() };
|
|
196
|
+
validateProfile(updated);
|
|
197
|
+
profiles[idx] = updated;
|
|
198
|
+
await store().set({ key: PROFILES_KEY, value: profiles });
|
|
199
|
+
return updated;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function deleteProfile(profileId) {
|
|
203
|
+
let profiles = await getProfiles();
|
|
204
|
+
profiles = profiles.filter((p) => p.id !== profileId);
|
|
205
|
+
await store().set({ key: PROFILES_KEY, value: profiles });
|
|
206
|
+
clearHandles(profileId);
|
|
207
|
+
return { success: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function activateProfile(profileId) {
|
|
211
|
+
const profiles = await getProfiles();
|
|
212
|
+
for (const p of profiles) {
|
|
213
|
+
p.active = p.id === profileId;
|
|
214
|
+
}
|
|
215
|
+
await store().set({ key: PROFILES_KEY, value: profiles });
|
|
216
|
+
return profiles.find((p) => p.id === profileId);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateProfile(p) {
|
|
220
|
+
if (!VALID_STRATEGIES.includes(p.strategy)) throw new Error(`Invalid strategy "${p.strategy}"`);
|
|
221
|
+
if (!VALID_DIRECTIONS.includes(p.direction)) throw new Error(`Invalid direction "${p.direction}"`);
|
|
222
|
+
if (!VALID_CONFLICT_STRATEGIES.includes(p.conflictStrategy)) throw new Error(`Invalid conflict strategy "${p.conflictStrategy}"`);
|
|
223
|
+
if (!VALID_EXECUTION_MODES.includes(p.executionMode)) throw new Error(`Invalid execution mode "${p.executionMode}"`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Global settings
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
async function getGlobalSettings() {
|
|
231
|
+
const s = await store().get({ key: GLOBAL_SETTINGS_KEY });
|
|
232
|
+
return { ...DEFAULT_GLOBAL_SETTINGS, ...(s || {}) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function setGlobalSettings(partial) {
|
|
236
|
+
const current = await getGlobalSettings();
|
|
237
|
+
const merged = { ...current, ...partial, updatedAt: new Date().toISOString() };
|
|
238
|
+
await store().set({ key: GLOBAL_SETTINGS_KEY, value: merged });
|
|
239
|
+
return merged;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Back-compat: old flat settings → global + active profile
|
|
243
|
+
async function getSettings() {
|
|
244
|
+
const global = await getGlobalSettings();
|
|
245
|
+
const active = await getActiveProfile();
|
|
246
|
+
return { ...global, ...(active || {}), profiles: await getProfiles() };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function setSettings(partial) {
|
|
250
|
+
// Back-compat: writes to global settings
|
|
251
|
+
return setGlobalSettings(partial);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Status
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
async function getStatus() {
|
|
259
|
+
const s = await store().get({ key: STATUS_KEY });
|
|
260
|
+
const profiles = await getProfiles();
|
|
261
|
+
return {
|
|
262
|
+
profiles: profiles.map((p) => ({
|
|
263
|
+
id: p.id,
|
|
264
|
+
name: p.name,
|
|
265
|
+
active: p.active,
|
|
266
|
+
executionMode: p.executionMode,
|
|
267
|
+
enabled: p.enabled,
|
|
268
|
+
lastExecutedAt: p.lastExecutedAt,
|
|
269
|
+
nextExecutionAt: p.nextExecutionAt,
|
|
270
|
+
running: !!(s && s.runningProfiles && s.runningProfiles[p.id]),
|
|
271
|
+
})),
|
|
272
|
+
lastRunAt: s?.lastRunAt || null,
|
|
273
|
+
lastResult: s?.lastResult || null,
|
|
274
|
+
running: s?.running || false,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function setStatus(status) {
|
|
279
|
+
await store().set({ key: STATUS_KEY, value: status });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Scheduler helpers (mirrors sync-execution patterns)
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
function clearHandles(profileId) {
|
|
287
|
+
const h = schedulerHandles[profileId];
|
|
288
|
+
if (!h) return;
|
|
289
|
+
if (h.interval) clearInterval(h.interval);
|
|
290
|
+
if (h.timeout) clearTimeout(h.timeout);
|
|
291
|
+
if (h.cronJob && typeof h.cronJob.cancel === 'function') h.cronJob.cancel();
|
|
292
|
+
delete schedulerHandles[profileId];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function updateScheduler(profile) {
|
|
296
|
+
clearHandles(profile.id);
|
|
297
|
+
if (!profile.enabled || profile.executionMode !== 'scheduled') return;
|
|
298
|
+
const type = profile.scheduleType || 'interval';
|
|
299
|
+
if (type === 'external') return; // nothing to schedule in-process
|
|
300
|
+
|
|
301
|
+
const ms = (profile.scheduleInterval || 60) * 60 * 1000;
|
|
302
|
+
|
|
303
|
+
if (type === 'interval') {
|
|
304
|
+
schedulerHandles[profile.id] = {
|
|
305
|
+
interval: setInterval(() => runProfile(profile.id).catch((e) => log.error(`[media-sched] ${e.message}`)), ms),
|
|
306
|
+
};
|
|
307
|
+
} else if (type === 'timeout') {
|
|
308
|
+
async function chain() {
|
|
309
|
+
try { await runProfile(profile.id); } catch (e) { log.error(`[media-sched] ${e.message}`); }
|
|
310
|
+
const p = await getProfile(profile.id);
|
|
311
|
+
if (p && p.enabled && p.executionMode === 'scheduled' && p.scheduleType === 'timeout') {
|
|
312
|
+
schedulerHandles[profile.id] = { timeout: setTimeout(chain, ms) };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
schedulerHandles[profile.id] = { timeout: setTimeout(chain, ms) };
|
|
316
|
+
} else if (type === 'cron') {
|
|
317
|
+
try {
|
|
318
|
+
const cronTask = strapi.cron.add({ [profile.cronExpression]: () => runProfile(profile.id).catch((e) => log.error(`[media-sched] ${e.message}`)) });
|
|
319
|
+
schedulerHandles[profile.id] = { cronJob: cronTask };
|
|
320
|
+
} catch (e) {
|
|
321
|
+
log.warn(`[media-sched] cron add failed for ${profile.id}: ${e.message}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function initializeSchedulers() {
|
|
327
|
+
const profiles = await getProfiles();
|
|
328
|
+
for (const p of profiles) {
|
|
329
|
+
if (p.enabled && p.executionMode === 'scheduled') {
|
|
330
|
+
await updateScheduler(p);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function stopAllSchedulers() {
|
|
336
|
+
for (const id of Object.keys(schedulerHandles)) clearHandles(id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// DB-row sync helpers (sync plugin::upload.file metadata without file bytes)
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
async function syncDbRowPull(remoteFile, localFile, profile) {
|
|
344
|
+
// Update or create a local DB row for the remote file's metadata
|
|
345
|
+
const meta = {
|
|
346
|
+
name: remoteFile.name,
|
|
347
|
+
alternativeText: remoteFile.alternativeText || '',
|
|
348
|
+
caption: remoteFile.caption || '',
|
|
349
|
+
width: remoteFile.width || null,
|
|
350
|
+
height: remoteFile.height || null,
|
|
351
|
+
formats: remoteFile.formats || null,
|
|
352
|
+
mime: remoteFile.mime,
|
|
353
|
+
size: remoteFile.size,
|
|
354
|
+
ext: remoteFile.ext,
|
|
355
|
+
hash: remoteFile.hash,
|
|
356
|
+
url: remoteFile.url,
|
|
357
|
+
provider: remoteFile.provider || 'local',
|
|
358
|
+
folderPath: remoteFile.folderPath || '',
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (localFile) {
|
|
362
|
+
// Conflict resolution
|
|
363
|
+
if (profile.conflictStrategy === 'local_wins') return 'skipped';
|
|
364
|
+
if (profile.conflictStrategy === 'latest_wins') {
|
|
365
|
+
const remoteTs = new Date(remoteFile.updatedAt || 0).getTime();
|
|
366
|
+
const localTs = new Date(localFile.updatedAt || 0).getTime();
|
|
367
|
+
if (localTs >= remoteTs) return 'skipped';
|
|
368
|
+
}
|
|
369
|
+
await strapi.db.query('plugin::upload.file').update({ where: { id: localFile.id }, data: meta });
|
|
370
|
+
return 'updated';
|
|
371
|
+
}
|
|
372
|
+
await strapi.db.query('plugin::upload.file').create({ data: meta });
|
|
373
|
+
return 'created';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function syncDbRowPush(localFile, remoteFile, profile, remoteConfig) {
|
|
377
|
+
const meta = {
|
|
378
|
+
name: localFile.name,
|
|
379
|
+
alternativeText: localFile.alternativeText || '',
|
|
380
|
+
caption: localFile.caption || '',
|
|
381
|
+
mime: localFile.mime,
|
|
382
|
+
size: localFile.size,
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
if (remoteFile) {
|
|
386
|
+
if (profile.conflictStrategy === 'remote_wins') return 'skipped';
|
|
387
|
+
if (profile.conflictStrategy === 'latest_wins') {
|
|
388
|
+
const localTs = new Date(localFile.updatedAt || 0).getTime();
|
|
389
|
+
const remoteTs = new Date(remoteFile.updatedAt || 0).getTime();
|
|
390
|
+
if (remoteTs >= localTs) return 'skipped';
|
|
391
|
+
}
|
|
392
|
+
// Update remote metadata via REST
|
|
393
|
+
try {
|
|
394
|
+
const url = new URL(`/api/upload/files/${remoteFile.id}`, remoteConfig.baseUrl);
|
|
395
|
+
const res = await fetch(url.toString(), {
|
|
396
|
+
method: 'PUT',
|
|
397
|
+
headers: {
|
|
398
|
+
Authorization: `Bearer ${remoteConfig.apiToken}`,
|
|
399
|
+
'Content-Type': 'application/json',
|
|
400
|
+
},
|
|
401
|
+
body: JSON.stringify({ fileInfo: meta }),
|
|
402
|
+
});
|
|
403
|
+
if (!res.ok) return 'error';
|
|
404
|
+
} catch { return 'error'; }
|
|
405
|
+
return 'updated';
|
|
406
|
+
}
|
|
407
|
+
// New file — bytes must be pushed separately
|
|
408
|
+
return 'needs_bytes';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// URL strategy
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
function passesFilters(file, profile) {
|
|
416
|
+
const mime = file.mime || '';
|
|
417
|
+
if (profile.includeMime?.length && !profile.includeMime.some((p) => mime.startsWith(p))) return false;
|
|
418
|
+
if (profile.excludeMime?.length && profile.excludeMime.some((p) => mime.startsWith(p))) return false;
|
|
419
|
+
const name = file.name || '';
|
|
420
|
+
if (profile.excludePatterns?.length && profile.excludePatterns.some((p) => globLike(p, name))) return false;
|
|
421
|
+
if (profile.includePatterns?.length && !profile.includePatterns.some((p) => globLike(p, name))) return false;
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function globLike(pattern, name) {
|
|
426
|
+
// very small wildcard matcher: "*" -> ".*", "?" -> "."
|
|
427
|
+
const rx = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
|
428
|
+
return rx.test(name);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function indexBy(files, key) {
|
|
432
|
+
const map = new Map();
|
|
433
|
+
for (const f of files) {
|
|
434
|
+
const k = (f[key] || '').toString();
|
|
435
|
+
if (k) map.set(k, f);
|
|
436
|
+
}
|
|
437
|
+
return map;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* List remote upload files, page by page.
|
|
442
|
+
* Uses Strapi's /api/upload/files endpoint.
|
|
443
|
+
*/
|
|
444
|
+
async function* iterateRemoteFiles(remoteConfig, pageSize) {
|
|
445
|
+
let page = 1;
|
|
446
|
+
while (true) {
|
|
447
|
+
const url = new URL('/api/upload/files', remoteConfig.baseUrl);
|
|
448
|
+
url.searchParams.set('pagination[page]', String(page));
|
|
449
|
+
url.searchParams.set('pagination[pageSize]', String(pageSize));
|
|
450
|
+
url.searchParams.set('sort', 'updatedAt:asc');
|
|
451
|
+
|
|
452
|
+
const res = await fetch(url.toString(), {
|
|
453
|
+
headers: { Authorization: `Bearer ${remoteConfig.apiToken}` },
|
|
454
|
+
});
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
const body = await safeReadBody(res);
|
|
457
|
+
throw new Error(`Remote upload list failed (${res.status}): ${body}`);
|
|
458
|
+
}
|
|
459
|
+
const json = await res.json();
|
|
460
|
+
// Strapi v5 returns either { results, pagination } or a bare array
|
|
461
|
+
const results = Array.isArray(json) ? json : (json.results || json.data || []);
|
|
462
|
+
const pagination = Array.isArray(json) ? null : (json.pagination || json.meta?.pagination);
|
|
463
|
+
|
|
464
|
+
yield results;
|
|
465
|
+
|
|
466
|
+
const hasMore = pagination
|
|
467
|
+
? page < (pagination.pageCount ?? (pagination.total ? Math.ceil(pagination.total / pageSize) : 1))
|
|
468
|
+
: results.length === pageSize;
|
|
469
|
+
if (!hasMore || results.length === 0) break;
|
|
470
|
+
page += 1;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function* iterateLocalFiles(pageSize) {
|
|
475
|
+
let page = 1;
|
|
476
|
+
while (true) {
|
|
477
|
+
const results = await strapi.db.query('plugin::upload.file').findMany({
|
|
478
|
+
limit: pageSize,
|
|
479
|
+
offset: (page - 1) * pageSize,
|
|
480
|
+
orderBy: { updatedAt: 'asc' },
|
|
481
|
+
});
|
|
482
|
+
yield results || [];
|
|
483
|
+
if (!results || results.length < pageSize) break;
|
|
484
|
+
page += 1;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function downloadToBuffer(remoteConfig, file) {
|
|
489
|
+
const fileUrl = absoluteUrl(remoteConfig.baseUrl, file.url);
|
|
490
|
+
const res = await fetch(fileUrl, {
|
|
491
|
+
headers: { Authorization: `Bearer ${remoteConfig.apiToken}` },
|
|
492
|
+
});
|
|
493
|
+
if (!res.ok) throw new Error(`Download failed for ${file.name}: ${res.status}`);
|
|
494
|
+
const ab = await res.arrayBuffer();
|
|
495
|
+
return Buffer.from(ab);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function absoluteUrl(baseUrl, url) {
|
|
499
|
+
if (!url) return baseUrl;
|
|
500
|
+
if (/^https?:\/\//i.test(url)) return url;
|
|
501
|
+
return new URL(url, baseUrl).toString();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function safeReadBody(res) {
|
|
505
|
+
try { return await res.text(); } catch { return '<unreadable>'; }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function uploadBufferToRemote(remoteConfig, file, buffer) {
|
|
509
|
+
const form = new FormData();
|
|
510
|
+
const blob = new Blob([buffer], { type: file.mime || 'application/octet-stream' });
|
|
511
|
+
form.append('files', blob, file.name);
|
|
512
|
+
if (file.folderPath) form.append('path', file.folderPath);
|
|
513
|
+
|
|
514
|
+
const res = await fetch(new URL('/api/upload', remoteConfig.baseUrl).toString(), {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
headers: { Authorization: `Bearer ${remoteConfig.apiToken}` },
|
|
517
|
+
body: form,
|
|
518
|
+
});
|
|
519
|
+
if (!res.ok) {
|
|
520
|
+
const body = await safeReadBody(res);
|
|
521
|
+
throw new Error(`Upload failed for ${file.name}: ${res.status} ${body}`);
|
|
522
|
+
}
|
|
523
|
+
return res.json();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function uploadBufferToLocal(file, buffer) {
|
|
527
|
+
// Write buffer to a temp file so the upload service can process it
|
|
528
|
+
// the same way it handles multipart form uploads.
|
|
529
|
+
const os = require('node:os');
|
|
530
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'strapi-sync-media-'));
|
|
531
|
+
const ext = path.extname(file.name) || '';
|
|
532
|
+
const tmpFilePath = path.join(tmpDir, `upload${ext}`);
|
|
533
|
+
await fsp.writeFile(tmpFilePath, buffer);
|
|
534
|
+
|
|
535
|
+
const uploadService = strapi.plugin('upload').service('upload');
|
|
536
|
+
try {
|
|
537
|
+
const fileObj = {
|
|
538
|
+
filepath: tmpFilePath,
|
|
539
|
+
originalFilename: file.name,
|
|
540
|
+
mimetype: file.mime || 'application/octet-stream',
|
|
541
|
+
size: buffer.length,
|
|
542
|
+
};
|
|
543
|
+
const result = await uploadService.upload({
|
|
544
|
+
data: {
|
|
545
|
+
fileInfo: {
|
|
546
|
+
name: file.name,
|
|
547
|
+
caption: file.caption || '',
|
|
548
|
+
alternativeText: file.alternativeText || '',
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
files: fileObj,
|
|
552
|
+
});
|
|
553
|
+
return result;
|
|
554
|
+
} finally {
|
|
555
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function shouldSkip(localFile, remoteFile, settings) {
|
|
560
|
+
if (!localFile || !remoteFile) return false;
|
|
561
|
+
if (settings.skipIfSameSize && localFile.size === remoteFile.size && localFile.hash === remoteFile.hash) {
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Copy a page of files respecting settings.batchConcurrency.
|
|
569
|
+
*/
|
|
570
|
+
async function processBatch(items, worker, concurrency) {
|
|
571
|
+
const out = { success: 0, skipped: 0, errors: [] };
|
|
572
|
+
const c = Math.max(1, Math.min(concurrency || 1, 10));
|
|
573
|
+
let i = 0;
|
|
574
|
+
async function run() {
|
|
575
|
+
while (i < items.length) {
|
|
576
|
+
const idx = i++;
|
|
577
|
+
const item = items[idx];
|
|
578
|
+
try {
|
|
579
|
+
const r = await worker(item);
|
|
580
|
+
if (r === 'skipped') out.skipped++; else out.success++;
|
|
581
|
+
} catch (err) {
|
|
582
|
+
out.errors.push({ name: item?.name || String(idx), error: err.message });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
await Promise.all(Array.from({ length: c }, run));
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function syncMediaViaUrl(profile, globalSettings) {
|
|
591
|
+
const settings = { ...globalSettings, ...profile };
|
|
592
|
+
const configService = plugin().service('config');
|
|
593
|
+
const logService = plugin().service('syncLog');
|
|
594
|
+
const remoteConfig = await configService.getConfig({ safe: false });
|
|
595
|
+
if (!remoteConfig?.baseUrl) throw new Error('Remote server not configured');
|
|
596
|
+
|
|
597
|
+
const totals = { pushed: 0, pulled: 0, skipped: 0, dbRowsUpdated: 0, errors: [] };
|
|
598
|
+
const started = Date.now();
|
|
599
|
+
|
|
600
|
+
const localIndex = new Map();
|
|
601
|
+
for await (const batch of iterateLocalFiles(settings.pageSize)) {
|
|
602
|
+
for (const f of batch) localIndex.set(`${f.hash}|${f.name}`, f);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// PULL: remote -> local
|
|
606
|
+
if (settings.direction === 'pull' || settings.direction === 'both') {
|
|
607
|
+
for await (const remoteBatch of iterateRemoteFiles(remoteConfig, settings.pageSize)) {
|
|
608
|
+
const filtered = remoteBatch.filter((f) => passesFilters(f, profile));
|
|
609
|
+
const result = await processBatch(filtered, async (rf) => {
|
|
610
|
+
const key = `${rf.hash}|${rf.name}`;
|
|
611
|
+
const lf = localIndex.get(key);
|
|
612
|
+
|
|
613
|
+
// DB-row sync
|
|
614
|
+
if (profile.syncDbRows) {
|
|
615
|
+
const dbResult = await syncDbRowPull(rf, lf, profile);
|
|
616
|
+
if (dbResult === 'created' || dbResult === 'updated') totals.dbRowsUpdated++;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// File-byte sync
|
|
620
|
+
if (profile.syncFileBytes) {
|
|
621
|
+
if (shouldSkip(lf, rf, settings)) return 'skipped';
|
|
622
|
+
if (settings.dryRun) return 'success';
|
|
623
|
+
const buf = await downloadToBuffer(remoteConfig, rf);
|
|
624
|
+
await uploadBufferToLocal(rf, buf);
|
|
625
|
+
}
|
|
626
|
+
return 'success';
|
|
627
|
+
}, settings.batchConcurrency);
|
|
628
|
+
totals.pulled += result.success;
|
|
629
|
+
totals.skipped += result.skipped;
|
|
630
|
+
totals.errors.push(...result.errors);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// PUSH: local -> remote
|
|
635
|
+
if (settings.direction === 'push' || settings.direction === 'both') {
|
|
636
|
+
const remoteIndex = new Map();
|
|
637
|
+
for await (const remoteBatch of iterateRemoteFiles(remoteConfig, settings.pageSize)) {
|
|
638
|
+
for (const f of remoteBatch) remoteIndex.set(`${f.hash}|${f.name}`, f);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
for await (const localBatch of iterateLocalFiles(settings.pageSize)) {
|
|
642
|
+
const filtered = localBatch.filter((f) => passesFilters(f, profile));
|
|
643
|
+
const result = await processBatch(filtered, async (lf) => {
|
|
644
|
+
const key = `${lf.hash}|${lf.name}`;
|
|
645
|
+
const rf = remoteIndex.get(key);
|
|
646
|
+
|
|
647
|
+
// DB-row sync (push metadata)
|
|
648
|
+
if (profile.syncDbRows && rf) {
|
|
649
|
+
const dbResult = await syncDbRowPush(lf, rf, profile, remoteConfig);
|
|
650
|
+
if (dbResult === 'updated') totals.dbRowsUpdated++;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// File-byte sync
|
|
654
|
+
if (profile.syncFileBytes) {
|
|
655
|
+
if (shouldSkip(lf, rf, settings)) return 'skipped';
|
|
656
|
+
if (settings.dryRun) return 'success';
|
|
657
|
+
const buf = await readLocalFileBuffer(lf);
|
|
658
|
+
if (!buf) return 'skipped';
|
|
659
|
+
await uploadBufferToRemote(remoteConfig, lf, buf);
|
|
660
|
+
}
|
|
661
|
+
return 'success';
|
|
662
|
+
}, settings.batchConcurrency);
|
|
663
|
+
totals.pushed += result.success;
|
|
664
|
+
totals.skipped += result.skipped;
|
|
665
|
+
totals.errors.push(...result.errors);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const summary = {
|
|
670
|
+
strategy: 'url',
|
|
671
|
+
profileId: profile.id,
|
|
672
|
+
profileName: profile.name,
|
|
673
|
+
direction: settings.direction,
|
|
674
|
+
dryRun: !!settings.dryRun,
|
|
675
|
+
durationMs: Date.now() - started,
|
|
676
|
+
...totals,
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
await logService?.log?.({
|
|
680
|
+
action: 'media_sync',
|
|
681
|
+
contentType: 'plugin::upload.file',
|
|
682
|
+
direction: settings.direction,
|
|
683
|
+
status: totals.errors.length ? 'partial' : 'success',
|
|
684
|
+
message: `URL media sync [${profile.name}]: pushed=${totals.pushed}, pulled=${totals.pulled}, dbRows=${totals.dbRowsUpdated}, skipped=${totals.skipped}, errors=${totals.errors.length}`,
|
|
685
|
+
details: summary,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
return summary;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function readLocalFileBuffer(file) {
|
|
692
|
+
// Only works for the local upload provider. For remote providers we'd
|
|
693
|
+
// have to fetch via file.url — which is supported too.
|
|
694
|
+
if (file.provider && file.provider !== 'local' && file.url) {
|
|
695
|
+
try {
|
|
696
|
+
const res = await fetch(file.url);
|
|
697
|
+
if (!res.ok) return null;
|
|
698
|
+
const ab = await res.arrayBuffer();
|
|
699
|
+
return Buffer.from(ab);
|
|
700
|
+
} catch {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const uploadsDir = path.join(strapi.dirs?.static?.public || path.join(process.cwd(), 'public'), 'uploads');
|
|
705
|
+
const filename = file.hash && file.ext ? `${file.hash}${file.ext}` : file.name;
|
|
706
|
+
const full = path.join(uploadsDir, filename);
|
|
707
|
+
try {
|
|
708
|
+
return await fsp.readFile(full);
|
|
709
|
+
} catch {
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
// rsync strategy
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
function buildRsyncArgs(settings, mode) {
|
|
719
|
+
const args = (settings.rsyncArgs || '-avz').trim().split(/\s+/).filter(Boolean);
|
|
720
|
+
|
|
721
|
+
// SSH options if remote path looks like user@host:/path
|
|
722
|
+
const isRemote = /:/.test(settings.remoteMediaPath) && !/^[A-Za-z]:\\/.test(settings.remoteMediaPath);
|
|
723
|
+
if (isRemote && (settings.sshPort !== 22 || settings.sshIdentityFile)) {
|
|
724
|
+
const parts = ['ssh'];
|
|
725
|
+
if (settings.sshPort && settings.sshPort !== 22) parts.push('-p', String(settings.sshPort));
|
|
726
|
+
if (settings.sshIdentityFile) parts.push('-i', settings.sshIdentityFile);
|
|
727
|
+
args.push('-e', parts.join(' '));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
for (const p of settings.includePatterns || []) args.push('--include', p);
|
|
731
|
+
for (const p of settings.excludePatterns || []) args.push('--exclude', p);
|
|
732
|
+
|
|
733
|
+
if (settings.dryRun) args.push('--dry-run');
|
|
734
|
+
|
|
735
|
+
const src = mode === 'push' ? ensureTrailingSlash(settings.localMediaPath) : ensureTrailingSlash(settings.remoteMediaPath);
|
|
736
|
+
const dst = mode === 'push' ? settings.remoteMediaPath : settings.localMediaPath;
|
|
737
|
+
args.push(src, dst);
|
|
738
|
+
|
|
739
|
+
return args;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function ensureTrailingSlash(p) {
|
|
743
|
+
if (!p) return p;
|
|
744
|
+
return p.endsWith('/') || p.endsWith('\\') ? p : p + '/';
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function runRsync(settings, mode) {
|
|
748
|
+
return new Promise((resolve, reject) => {
|
|
749
|
+
const cmd = settings.rsyncCommand || 'rsync';
|
|
750
|
+
const args = buildRsyncArgs(settings, mode);
|
|
751
|
+
log.info(`[data-sync] rsync ${mode}: ${cmd} ${args.join(' ')}`);
|
|
752
|
+
|
|
753
|
+
const child = spawn(cmd, args, { shell: false });
|
|
754
|
+
let stdout = '';
|
|
755
|
+
let stderr = '';
|
|
756
|
+
const timeout = setTimeout(() => {
|
|
757
|
+
try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
|
|
758
|
+
reject(new Error(`rsync timed out after ${settings.rsyncTimeoutMs}ms`));
|
|
759
|
+
}, settings.rsyncTimeoutMs || 30 * 60 * 1000);
|
|
760
|
+
|
|
761
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
762
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
763
|
+
child.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
764
|
+
child.on('close', (code) => {
|
|
765
|
+
clearTimeout(timeout);
|
|
766
|
+
if (code === 0) resolve({ mode, stdout, stderr });
|
|
767
|
+
else reject(new Error(`rsync exited with code ${code}: ${stderr || stdout}`));
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function syncMediaViaRsync(profile, globalSettings) {
|
|
773
|
+
const settings = { ...globalSettings, ...profile };
|
|
774
|
+
const logService = plugin().service('syncLog');
|
|
775
|
+
const started = Date.now();
|
|
776
|
+
const results = [];
|
|
777
|
+
|
|
778
|
+
if (settings.direction === 'push' || settings.direction === 'both') {
|
|
779
|
+
results.push(await runRsync(settings, 'push'));
|
|
780
|
+
}
|
|
781
|
+
if (settings.direction === 'pull' || settings.direction === 'both') {
|
|
782
|
+
results.push(await runRsync(settings, 'pull'));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const summary = {
|
|
786
|
+
strategy: 'rsync',
|
|
787
|
+
profileId: profile.id,
|
|
788
|
+
profileName: profile.name,
|
|
789
|
+
direction: settings.direction,
|
|
790
|
+
dryRun: !!settings.dryRun,
|
|
791
|
+
durationMs: Date.now() - started,
|
|
792
|
+
runs: results.map((r) => ({ mode: r.mode, stdoutTail: tail(r.stdout), stderrTail: tail(r.stderr) })),
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
await logService?.log?.({
|
|
796
|
+
action: 'media_sync',
|
|
797
|
+
contentType: 'plugin::upload.file',
|
|
798
|
+
direction: settings.direction,
|
|
799
|
+
status: 'success',
|
|
800
|
+
message: `rsync media sync [${profile.name}] (${settings.direction}) completed in ${summary.durationMs}ms`,
|
|
801
|
+
details: summary,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
return summary;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function tail(text, lines = 20) {
|
|
808
|
+
if (!text) return '';
|
|
809
|
+
const arr = text.split(/\r?\n/);
|
|
810
|
+
return arr.slice(Math.max(0, arr.length - lines)).join('\n');
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// Profile execution
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
async function runProfile(profileId, options = {}) {
|
|
818
|
+
const profile = await getProfile(profileId);
|
|
819
|
+
if (!profile) throw new Error(`Media profile "${profileId}" not found`);
|
|
820
|
+
if (profile.strategy === 'disabled') throw new Error(`Profile "${profile.name}" has strategy disabled.`);
|
|
821
|
+
|
|
822
|
+
const globalSettings = await getGlobalSettings();
|
|
823
|
+
const merged = { ...globalSettings, ...profile, ...options };
|
|
824
|
+
|
|
825
|
+
const statusData = await store().get({ key: STATUS_KEY }) || {};
|
|
826
|
+
statusData.running = true;
|
|
827
|
+
statusData.runningProfiles = { ...(statusData.runningProfiles || {}), [profileId]: true };
|
|
828
|
+
await setStatus(statusData);
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
let result;
|
|
832
|
+
if (merged.strategy === 'rsync') {
|
|
833
|
+
result = await syncMediaViaRsync(merged, globalSettings);
|
|
834
|
+
} else {
|
|
835
|
+
result = await syncMediaViaUrl(merged, globalSettings);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Update profile last execution
|
|
839
|
+
await updateProfile(profileId, { lastExecutedAt: new Date().toISOString() });
|
|
840
|
+
|
|
841
|
+
const s2 = await store().get({ key: STATUS_KEY }) || {};
|
|
842
|
+
delete (s2.runningProfiles || {})[profileId];
|
|
843
|
+
s2.running = Object.keys(s2.runningProfiles || {}).length > 0;
|
|
844
|
+
s2.lastRunAt = new Date().toISOString();
|
|
845
|
+
s2.lastResult = result;
|
|
846
|
+
await setStatus(s2);
|
|
847
|
+
|
|
848
|
+
return result;
|
|
849
|
+
} catch (err) {
|
|
850
|
+
const s2 = await store().get({ key: STATUS_KEY }) || {};
|
|
851
|
+
delete (s2.runningProfiles || {})[profileId];
|
|
852
|
+
s2.running = Object.keys(s2.runningProfiles || {}).length > 0;
|
|
853
|
+
s2.lastRunAt = new Date().toISOString();
|
|
854
|
+
s2.lastResult = { error: err.message };
|
|
855
|
+
await setStatus(s2);
|
|
856
|
+
throw err;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function runActiveProfiles() {
|
|
861
|
+
const profiles = await getProfiles();
|
|
862
|
+
const active = profiles.filter((p) => p.active && p.strategy !== 'disabled');
|
|
863
|
+
const results = [];
|
|
864
|
+
for (const p of active) {
|
|
865
|
+
try {
|
|
866
|
+
results.push(await runProfile(p.id));
|
|
867
|
+
} catch (err) {
|
|
868
|
+
results.push({ profileId: p.id, error: err.message });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return results;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// ---------------------------------------------------------------------------
|
|
875
|
+
// Public API
|
|
876
|
+
// ---------------------------------------------------------------------------
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
// Profile CRUD
|
|
880
|
+
getProfiles,
|
|
881
|
+
getProfile,
|
|
882
|
+
getActiveProfile,
|
|
883
|
+
createProfile,
|
|
884
|
+
updateProfile,
|
|
885
|
+
deleteProfile,
|
|
886
|
+
activateProfile,
|
|
887
|
+
|
|
888
|
+
// Global settings
|
|
889
|
+
getGlobalSettings,
|
|
890
|
+
setGlobalSettings,
|
|
891
|
+
|
|
892
|
+
// Back-compat
|
|
893
|
+
getSettings,
|
|
894
|
+
setSettings,
|
|
895
|
+
|
|
896
|
+
// Status
|
|
897
|
+
getStatus,
|
|
898
|
+
|
|
899
|
+
// Execution
|
|
900
|
+
runProfile,
|
|
901
|
+
runActiveProfiles,
|
|
902
|
+
|
|
903
|
+
// Schedulers
|
|
904
|
+
initializeSchedulers,
|
|
905
|
+
stopAllSchedulers,
|
|
906
|
+
updateScheduler,
|
|
907
|
+
|
|
908
|
+
// Constants for UI
|
|
909
|
+
getDefaults() {
|
|
910
|
+
return {
|
|
911
|
+
mimeImages: DEFAULT_MIME_IMAGES,
|
|
912
|
+
mimeVideos: DEFAULT_MIME_VIDEOS,
|
|
913
|
+
mimeDocuments: DEFAULT_MIME_DOCUMENTS,
|
|
914
|
+
mimeAll: DEFAULT_MIME_ALL,
|
|
915
|
+
strategies: VALID_STRATEGIES,
|
|
916
|
+
directions: VALID_DIRECTIONS,
|
|
917
|
+
conflictStrategies: VALID_CONFLICT_STRATEGIES,
|
|
918
|
+
executionModes: VALID_EXECUTION_MODES,
|
|
919
|
+
scheduleTypes: VALID_SCHEDULE_TYPES,
|
|
920
|
+
};
|
|
921
|
+
},
|
|
922
|
+
|
|
923
|
+
// Legacy run (back-compat for existing /media-sync/run endpoint)
|
|
924
|
+
async run(options = {}) {
|
|
925
|
+
if (options.profileId) {
|
|
926
|
+
return runProfile(options.profileId, options);
|
|
927
|
+
}
|
|
928
|
+
// Run the active profile
|
|
929
|
+
const active = await getActiveProfile();
|
|
930
|
+
if (!active) throw new Error('No active media profile. Create or activate one in the Media tab.');
|
|
931
|
+
return runProfile(active.id, options);
|
|
932
|
+
},
|
|
933
|
+
|
|
934
|
+
async testConnection() {
|
|
935
|
+
const globalSettings = await getGlobalSettings();
|
|
936
|
+
if (globalSettings.rsyncCommand && globalSettings.localMediaPath) {
|
|
937
|
+
// rsync test
|
|
938
|
+
return new Promise((resolve) => {
|
|
939
|
+
const child = spawn(globalSettings.rsyncCommand || 'rsync', ['--version'], { shell: false });
|
|
940
|
+
let out = '';
|
|
941
|
+
child.stdout.on('data', (d) => { out += d.toString(); });
|
|
942
|
+
child.on('error', (err) => resolve({ ok: false, error: err.message }));
|
|
943
|
+
child.on('close', (code) => resolve({ ok: code === 0, version: out.split(/\r?\n/)[0] || '' }));
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
// URL strategy test
|
|
947
|
+
const configService = plugin().service('config');
|
|
948
|
+
const remoteConfig = await configService.getConfig({ safe: false });
|
|
949
|
+
if (!remoteConfig?.baseUrl) return { ok: false, error: 'Remote server not configured' };
|
|
950
|
+
try {
|
|
951
|
+
const url = new URL('/api/upload/files', remoteConfig.baseUrl);
|
|
952
|
+
url.searchParams.set('pagination[pageSize]', '1');
|
|
953
|
+
const res = await fetch(url.toString(), {
|
|
954
|
+
headers: { Authorization: `Bearer ${remoteConfig.apiToken}` },
|
|
955
|
+
});
|
|
956
|
+
if (!res.ok) return { ok: false, error: `Remote ${res.status}: ${await safeReadBody(res)}` };
|
|
957
|
+
return { ok: true };
|
|
958
|
+
} catch (err) {
|
|
959
|
+
return { ok: false, error: err.message };
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
};
|
|
963
|
+
};
|