strapi-security-suite 0.3.2 → 0.4.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/README.md +171 -132
- package/dist/_chunks/{App-ConqHB2Q.js → App-B-CRozv4.js} +1 -1
- package/dist/_chunks/{App-CBOxzfqu.mjs → App-CM1kp54o.mjs} +1 -1
- package/dist/_chunks/{index-BGBd43He.js → index-BVsx1rse.js} +35 -3
- package/dist/_chunks/{index-ZKJuPZEH.mjs → index-CrITOuMT.mjs} +35 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +589 -138
- package/dist/server/index.mjs +589 -138
- package/package.json +9 -2
package/dist/server/index.mjs
CHANGED
|
@@ -1,33 +1,31 @@
|
|
|
1
1
|
import jwt from "jsonwebtoken";
|
|
2
|
-
|
|
3
|
-
const revokedConnectionTokens = /* @__PURE__ */ new Map();
|
|
4
|
-
const TOKEN_TTL = 30 * 60 * 1e3;
|
|
5
|
-
function pruneExpiredTokens() {
|
|
6
|
-
const cutoff = Date.now() - TOKEN_TTL;
|
|
7
|
-
for (const [token, revokedAt] of revokedConnectionTokens) {
|
|
8
|
-
if (revokedAt < cutoff) {
|
|
9
|
-
revokedConnectionTokens.delete(token);
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
const sessionActivityMap = /* @__PURE__ */ new Map();
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
14
3
|
const PLUGIN_ID = "strapi-security-suite";
|
|
15
4
|
const CONTENT_TYPES = {
|
|
16
|
-
SECURITY_SETTINGS: `plugin::${PLUGIN_ID}.security-settings
|
|
5
|
+
SECURITY_SETTINGS: `plugin::${PLUGIN_ID}.security-settings`,
|
|
6
|
+
ADMIN_SESSION: `plugin::${PLUGIN_ID}.admin-session`,
|
|
7
|
+
LOGIN_LOCK: `plugin::${PLUGIN_ID}.login-lock`,
|
|
8
|
+
WATCHER_LEASE: `plugin::${PLUGIN_ID}.watcher-lease`
|
|
17
9
|
};
|
|
18
10
|
const SERVICES = {
|
|
19
|
-
AUTO_LOGOUT_CHECKER: "autoLogoutChecker"
|
|
11
|
+
AUTO_LOGOUT_CHECKER: "autoLogoutChecker",
|
|
12
|
+
STATE: "state"
|
|
20
13
|
};
|
|
21
14
|
const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser");
|
|
15
|
+
const WATCHER_LEASE_NAME = "autologout-watcher";
|
|
22
16
|
const CHECK_INTERVAL = 5e3;
|
|
23
17
|
const DEFAULT_AUTOLOGOUT_TIME = 30;
|
|
24
18
|
const MS_PER_MINUTE = 6e4;
|
|
25
19
|
const MS_PER_SECOND = 1e3;
|
|
20
|
+
const ACTIVITY_FLUSH_INTERVAL_MS = 30 * MS_PER_SECOND;
|
|
21
|
+
const LOGIN_LOCK_TTL_MS = 10 * MS_PER_SECOND;
|
|
22
|
+
const WATCHER_LEASE_TTL_MS = 15 * MS_PER_SECOND;
|
|
26
23
|
const LOGIN_PATH = "/admin/login";
|
|
27
24
|
const LOGOUT_PATH = "/admin/logout";
|
|
28
25
|
const ACCESS_TOKEN_PATH = "/access-token";
|
|
29
26
|
const CONTENT_PATH = "/content";
|
|
30
27
|
const HTTP_STATUS = {
|
|
28
|
+
NO_CONTENT: 204,
|
|
31
29
|
BAD_REQUEST: 400,
|
|
32
30
|
FORBIDDEN: 403,
|
|
33
31
|
CONFLICT: 409
|
|
@@ -103,17 +101,22 @@ function clearSessionCookies(ctx) {
|
|
|
103
101
|
path: "/admin",
|
|
104
102
|
httpOnly: true
|
|
105
103
|
});
|
|
106
|
-
|
|
104
|
+
const configuredSecure = strapi.config.get("admin.auth.cookie.secure");
|
|
105
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
106
|
+
const jwtClearOpts = {
|
|
107
107
|
expires: /* @__PURE__ */ new Date(0),
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
httpOnly: false,
|
|
109
|
+
secure: typeof configuredSecure === "boolean" ? configuredSecure : isProduction,
|
|
110
|
+
domain: strapi.config.get("admin.auth.domain"),
|
|
111
|
+
overwrite: true
|
|
112
|
+
};
|
|
113
|
+
ctx.cookies.set(COOKIES.JWT_TOKEN, "", jwtClearOpts);
|
|
111
114
|
}
|
|
112
115
|
async function trackActivity(ctx, next) {
|
|
113
116
|
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
118
|
+
if (adminUser?.sessionId && await state2.isRevoked(adminUser.sessionId)) {
|
|
119
|
+
clearSessionCookies(ctx);
|
|
117
120
|
ctx.status = HTTP_STATUS.FORBIDDEN;
|
|
118
121
|
ctx.body = {
|
|
119
122
|
error: {
|
|
@@ -126,21 +129,23 @@ async function trackActivity(ctx, next) {
|
|
|
126
129
|
}
|
|
127
130
|
if (ctx.path.includes(LOGOUT_PATH)) {
|
|
128
131
|
clearSessionCookies(ctx);
|
|
129
|
-
|
|
132
|
+
return await next();
|
|
130
133
|
}
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
if (adminUser?.sessionId && adminUser?.id && adminUser?.email) {
|
|
135
|
+
await state2.touch({
|
|
136
|
+
sessionId: adminUser.sessionId,
|
|
137
|
+
userId: adminUser.id,
|
|
138
|
+
email: adminUser.email
|
|
139
|
+
});
|
|
140
|
+
strapi.log.debug(`[${PLUGIN_ID}] Activity touched: ${adminUser.id}:${adminUser.email}`);
|
|
134
141
|
}
|
|
135
142
|
await next();
|
|
136
143
|
}
|
|
137
|
-
|
|
138
|
-
function cleanupLoginState(ctx) {
|
|
144
|
+
async function cleanupLoginState(ctx) {
|
|
139
145
|
const email = ctx.request.body?.email;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
146
|
+
if (!email) return;
|
|
147
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
148
|
+
await state2.releaseLoginLock({ email });
|
|
144
149
|
}
|
|
145
150
|
async function preventMultipleSessions(ctx, next) {
|
|
146
151
|
const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
|
|
@@ -153,21 +158,32 @@ async function preventMultipleSessions(ctx, next) {
|
|
|
153
158
|
);
|
|
154
159
|
return await next();
|
|
155
160
|
}
|
|
161
|
+
const { email } = ctx.request.body ?? {};
|
|
162
|
+
if (!email) {
|
|
163
|
+
strapi.log.warn(`[${PLUGIN_ID}] Email missing in login request. Skipping session lock.`);
|
|
164
|
+
return await next();
|
|
165
|
+
}
|
|
166
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
167
|
+
let lockAcquired = false;
|
|
156
168
|
try {
|
|
157
|
-
const { email } = ctx.request.body ?? {};
|
|
158
|
-
if (!email) {
|
|
159
|
-
strapi.log.warn(`[${PLUGIN_ID}] Email missing in login request. Skipping session lock.`);
|
|
160
|
-
return await next();
|
|
161
|
-
}
|
|
162
169
|
const settings = await strapi.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
|
|
163
170
|
if (!settings?.multipleSessionsControl) return await next();
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
const idleThresholdMs = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
|
|
172
|
+
const hasActive = await state2.hasActiveSession({ email, idleThresholdMs });
|
|
173
|
+
if (hasActive) {
|
|
174
|
+
strapi.log.warn(`[${PLUGIN_ID}] Login blocked for ${email}: already logged in.`);
|
|
175
|
+
ctx.status = HTTP_STATUS.CONFLICT;
|
|
176
|
+
ctx.body = {
|
|
177
|
+
error: {
|
|
178
|
+
status: HTTP_STATUS.CONFLICT,
|
|
179
|
+
message: ERROR_MESSAGES.MULTIPLE_SESSIONS
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
lockAcquired = await state2.acquireLoginLock({ email });
|
|
185
|
+
if (!lockAcquired) {
|
|
186
|
+
strapi.log.warn(`[${PLUGIN_ID}] Login blocked for ${email}: another login is in flight.`);
|
|
171
187
|
ctx.status = HTTP_STATUS.CONFLICT;
|
|
172
188
|
ctx.body = {
|
|
173
189
|
error: {
|
|
@@ -177,44 +193,40 @@ async function preventMultipleSessions(ctx, next) {
|
|
|
177
193
|
};
|
|
178
194
|
return;
|
|
179
195
|
}
|
|
180
|
-
loginLocks.add(email);
|
|
181
196
|
} catch (err) {
|
|
182
197
|
strapi.log.error(`[${PLUGIN_ID}] Error in preventMultipleSessions:`, err);
|
|
183
198
|
}
|
|
184
199
|
try {
|
|
185
200
|
await next();
|
|
186
201
|
} finally {
|
|
187
|
-
|
|
202
|
+
if (lockAcquired) {
|
|
203
|
+
await cleanupLoginState(ctx);
|
|
204
|
+
}
|
|
188
205
|
}
|
|
189
206
|
}
|
|
190
207
|
async function rejectRevokedTokens(ctx, next) {
|
|
191
208
|
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
192
|
-
if (!adminUser?.
|
|
193
|
-
const
|
|
194
|
-
const key = id && adminEmail ? `${id}:${adminEmail}` : null;
|
|
209
|
+
if (!adminUser?.sessionId) return await next();
|
|
210
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
195
211
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
sessionActivityMap.delete(key);
|
|
205
|
-
revokedTokenSet.delete(adminEmail);
|
|
206
|
-
strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(id, adminEmail);
|
|
207
|
-
try {
|
|
208
|
-
if (strapi.sessionManager) {
|
|
209
|
-
await strapi.sessionManager("admin").invalidateRefreshToken(String(id));
|
|
210
|
-
}
|
|
211
|
-
} catch (err) {
|
|
212
|
-
strapi.log.error(`[${PLUGIN_ID}] Failed to invalidate DB session for ${adminEmail}:`, err);
|
|
212
|
+
const revoked = await state2.isRevoked(adminUser.sessionId);
|
|
213
|
+
if (!revoked) return await next();
|
|
214
|
+
ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminUser.email || "");
|
|
215
|
+
ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
|
|
216
|
+
clearSessionCookies(ctx);
|
|
217
|
+
try {
|
|
218
|
+
if (strapi.sessionManager && adminUser.id) {
|
|
219
|
+
await strapi.sessionManager("admin").invalidateRefreshToken(String(adminUser.id));
|
|
213
220
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
221
|
+
} catch (err) {
|
|
222
|
+
strapi.log.error(
|
|
223
|
+
`[${PLUGIN_ID}] Failed to invalidate DB session for ${adminUser.email}:`,
|
|
224
|
+
err
|
|
225
|
+
);
|
|
217
226
|
}
|
|
227
|
+
strapi.log.info(
|
|
228
|
+
`[${PLUGIN_ID}] Session revoked: ${adminUser.email} (session ${adminUser.sessionId})`
|
|
229
|
+
);
|
|
218
230
|
} catch (err) {
|
|
219
231
|
strapi.log.error(`[${PLUGIN_ID}] Error in rejectRevokedTokens middleware:`, err);
|
|
220
232
|
}
|
|
@@ -224,15 +236,11 @@ async function interceptRenewToken(ctx, next) {
|
|
|
224
236
|
if (ctx.path.includes(LOGOUT_PATH)) {
|
|
225
237
|
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
226
238
|
strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`);
|
|
227
|
-
if (adminUser?.
|
|
228
|
-
strapi.plugin(PLUGIN_ID).service(SERVICES.
|
|
229
|
-
|
|
230
|
-
if (bearerToken) {
|
|
231
|
-
revokedConnectionTokens.set(bearerToken, Date.now());
|
|
232
|
-
}
|
|
233
|
-
clearSessionCookies(ctx);
|
|
234
|
-
sessionActivityMap.delete(`${adminUser.id}:${adminUser.email}`);
|
|
239
|
+
if (adminUser?.sessionId) {
|
|
240
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
241
|
+
await state2.revokeSession({ sessionId: adminUser.sessionId });
|
|
235
242
|
}
|
|
243
|
+
clearSessionCookies(ctx);
|
|
236
244
|
await next();
|
|
237
245
|
return;
|
|
238
246
|
}
|
|
@@ -254,6 +262,7 @@ async function seedUserInfos(ctx, next) {
|
|
|
254
262
|
if (!token) return await next();
|
|
255
263
|
const decodedToken = jwt.decode(token);
|
|
256
264
|
const adminId = decodedToken?.userId;
|
|
265
|
+
const sessionId = decodedToken?.sessionId;
|
|
257
266
|
if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) {
|
|
258
267
|
return await next();
|
|
259
268
|
}
|
|
@@ -267,16 +276,13 @@ async function seedUserInfos(ctx, next) {
|
|
|
267
276
|
}
|
|
268
277
|
ctx.state[CTX_ADMIN_USER] = {
|
|
269
278
|
id: adminUser.id,
|
|
279
|
+
sessionId,
|
|
270
280
|
email: adminUser.email,
|
|
271
281
|
firstname: adminUser.firstname,
|
|
272
282
|
lastname: adminUser.lastname,
|
|
273
283
|
roles: adminUser.roles
|
|
274
284
|
};
|
|
275
|
-
|
|
276
|
-
if (!sessionActivityMap.has(key)) {
|
|
277
|
-
sessionActivityMap.set(key, Date.now());
|
|
278
|
-
}
|
|
279
|
-
strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email}`);
|
|
285
|
+
strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email} (session ${sessionId})`);
|
|
280
286
|
return await next();
|
|
281
287
|
} catch (error) {
|
|
282
288
|
strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error);
|
|
@@ -333,8 +339,8 @@ async function ensureDefaultSecuritySettings(strapi2) {
|
|
|
333
339
|
strapi2.log.error(`[${PLUGIN_ID}] Failed to ensure default security settings:`, error);
|
|
334
340
|
}
|
|
335
341
|
}
|
|
336
|
-
const destroy = ({ strapi: strapi2 }) => {
|
|
337
|
-
strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).stopAutoLogoutWatcher();
|
|
342
|
+
const destroy = async ({ strapi: strapi2 }) => {
|
|
343
|
+
await strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).stopAutoLogoutWatcher();
|
|
338
344
|
};
|
|
339
345
|
const register = ({ strapi: strapi2 }) => {
|
|
340
346
|
strapi2.server.use(middlewares.seedUserInfos);
|
|
@@ -355,17 +361,17 @@ const config = {
|
|
|
355
361
|
validator(_config) {
|
|
356
362
|
}
|
|
357
363
|
};
|
|
358
|
-
const kind = "singleType";
|
|
359
|
-
const info = {
|
|
364
|
+
const kind$3 = "singleType";
|
|
365
|
+
const info$3 = {
|
|
360
366
|
singularName: "security-settings",
|
|
361
367
|
pluralName: "security-settings",
|
|
362
368
|
displayName: "Security Settings",
|
|
363
369
|
description: "Stores security and session settings"
|
|
364
370
|
};
|
|
365
|
-
const options = {
|
|
371
|
+
const options$3 = {
|
|
366
372
|
draftAndPublish: false
|
|
367
373
|
};
|
|
368
|
-
const pluginOptions = {
|
|
374
|
+
const pluginOptions$3 = {
|
|
369
375
|
"content-manager": {
|
|
370
376
|
visible: false
|
|
371
377
|
},
|
|
@@ -373,7 +379,7 @@ const pluginOptions = {
|
|
|
373
379
|
visible: false
|
|
374
380
|
}
|
|
375
381
|
};
|
|
376
|
-
const attributes = {
|
|
382
|
+
const attributes$3 = {
|
|
377
383
|
autoLogoutTime: {
|
|
378
384
|
type: "integer",
|
|
379
385
|
required: true,
|
|
@@ -397,18 +403,157 @@ const attributes = {
|
|
|
397
403
|
"default": true
|
|
398
404
|
}
|
|
399
405
|
};
|
|
406
|
+
const schema$3 = {
|
|
407
|
+
kind: kind$3,
|
|
408
|
+
info: info$3,
|
|
409
|
+
options: options$3,
|
|
410
|
+
pluginOptions: pluginOptions$3,
|
|
411
|
+
attributes: attributes$3
|
|
412
|
+
};
|
|
413
|
+
const securitySettings = {
|
|
414
|
+
schema: schema$3
|
|
415
|
+
};
|
|
416
|
+
const kind$2 = "collectionType";
|
|
417
|
+
const collectionName$2 = "sss_admin_sessions";
|
|
418
|
+
const info$2 = {
|
|
419
|
+
singularName: "admin-session",
|
|
420
|
+
pluralName: "admin-sessions",
|
|
421
|
+
displayName: "Admin Session",
|
|
422
|
+
description: "Per-session activity and revocation state for admin users (managed by strapi-security-suite)."
|
|
423
|
+
};
|
|
424
|
+
const options$2 = {
|
|
425
|
+
draftAndPublish: false
|
|
426
|
+
};
|
|
427
|
+
const pluginOptions$2 = {
|
|
428
|
+
"content-manager": {
|
|
429
|
+
visible: false
|
|
430
|
+
},
|
|
431
|
+
"content-type-builder": {
|
|
432
|
+
visible: false
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const attributes$2 = {
|
|
436
|
+
sessionId: {
|
|
437
|
+
type: "string",
|
|
438
|
+
required: true,
|
|
439
|
+
unique: true
|
|
440
|
+
},
|
|
441
|
+
userId: {
|
|
442
|
+
type: "integer",
|
|
443
|
+
required: true
|
|
444
|
+
},
|
|
445
|
+
email: {
|
|
446
|
+
type: "string",
|
|
447
|
+
required: true
|
|
448
|
+
},
|
|
449
|
+
lastActiveAt: {
|
|
450
|
+
type: "datetime",
|
|
451
|
+
required: true
|
|
452
|
+
},
|
|
453
|
+
revokedAt: {
|
|
454
|
+
type: "datetime"
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const schema$2 = {
|
|
458
|
+
kind: kind$2,
|
|
459
|
+
collectionName: collectionName$2,
|
|
460
|
+
info: info$2,
|
|
461
|
+
options: options$2,
|
|
462
|
+
pluginOptions: pluginOptions$2,
|
|
463
|
+
attributes: attributes$2
|
|
464
|
+
};
|
|
465
|
+
const adminSession = {
|
|
466
|
+
schema: schema$2
|
|
467
|
+
};
|
|
468
|
+
const kind$1 = "collectionType";
|
|
469
|
+
const collectionName$1 = "sss_login_locks";
|
|
470
|
+
const info$1 = {
|
|
471
|
+
singularName: "login-lock",
|
|
472
|
+
pluralName: "login-locks",
|
|
473
|
+
displayName: "Login Lock",
|
|
474
|
+
description: "Cross-pod login lock keyed by email (managed by strapi-security-suite)."
|
|
475
|
+
};
|
|
476
|
+
const options$1 = {
|
|
477
|
+
draftAndPublish: false
|
|
478
|
+
};
|
|
479
|
+
const pluginOptions$1 = {
|
|
480
|
+
"content-manager": {
|
|
481
|
+
visible: false
|
|
482
|
+
},
|
|
483
|
+
"content-type-builder": {
|
|
484
|
+
visible: false
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const attributes$1 = {
|
|
488
|
+
email: {
|
|
489
|
+
type: "string",
|
|
490
|
+
required: true,
|
|
491
|
+
unique: true
|
|
492
|
+
},
|
|
493
|
+
lockedUntil: {
|
|
494
|
+
type: "datetime",
|
|
495
|
+
required: true
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
const schema$1 = {
|
|
499
|
+
kind: kind$1,
|
|
500
|
+
collectionName: collectionName$1,
|
|
501
|
+
info: info$1,
|
|
502
|
+
options: options$1,
|
|
503
|
+
pluginOptions: pluginOptions$1,
|
|
504
|
+
attributes: attributes$1
|
|
505
|
+
};
|
|
506
|
+
const loginLock = {
|
|
507
|
+
schema: schema$1
|
|
508
|
+
};
|
|
509
|
+
const kind = "collectionType";
|
|
510
|
+
const collectionName = "sss_watcher_leases";
|
|
511
|
+
const info = {
|
|
512
|
+
singularName: "watcher-lease",
|
|
513
|
+
pluralName: "watcher-leases",
|
|
514
|
+
displayName: "Watcher Lease",
|
|
515
|
+
description: "Cross-pod leader-election lease for the auto-logout watcher (managed by strapi-security-suite)."
|
|
516
|
+
};
|
|
517
|
+
const options = {
|
|
518
|
+
draftAndPublish: false
|
|
519
|
+
};
|
|
520
|
+
const pluginOptions = {
|
|
521
|
+
"content-manager": {
|
|
522
|
+
visible: false
|
|
523
|
+
},
|
|
524
|
+
"content-type-builder": {
|
|
525
|
+
visible: false
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
const attributes = {
|
|
529
|
+
name: {
|
|
530
|
+
type: "string",
|
|
531
|
+
required: true,
|
|
532
|
+
unique: true
|
|
533
|
+
},
|
|
534
|
+
holder: {
|
|
535
|
+
type: "string"
|
|
536
|
+
},
|
|
537
|
+
expiresAt: {
|
|
538
|
+
type: "datetime"
|
|
539
|
+
}
|
|
540
|
+
};
|
|
400
541
|
const schema = {
|
|
401
542
|
kind,
|
|
543
|
+
collectionName,
|
|
402
544
|
info,
|
|
403
545
|
options,
|
|
404
546
|
pluginOptions,
|
|
405
547
|
attributes
|
|
406
548
|
};
|
|
407
|
-
const
|
|
549
|
+
const watcherLease = {
|
|
408
550
|
schema
|
|
409
551
|
};
|
|
410
552
|
const contentTypes = {
|
|
411
|
-
"security-settings": securitySettings
|
|
553
|
+
"security-settings": securitySettings,
|
|
554
|
+
"admin-session": adminSession,
|
|
555
|
+
"login-lock": loginLock,
|
|
556
|
+
"watcher-lease": watcherLease
|
|
412
557
|
};
|
|
413
558
|
const validateSettingsPayload = (body) => {
|
|
414
559
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -487,6 +632,18 @@ const adminSecurityController = ({ strapi: strapi2 }) => ({
|
|
|
487
632
|
err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR
|
|
488
633
|
);
|
|
489
634
|
}
|
|
635
|
+
},
|
|
636
|
+
/**
|
|
637
|
+
* Lightweight keep-alive endpoint hit by the admin client while the user
|
|
638
|
+
* is actively interacting with the UI (mouse / keyboard). The middleware
|
|
639
|
+
* chain (`seedUserInfos` → `trackActivity`) does the actual work of
|
|
640
|
+
* persisting `lastActiveAt` for the current session, so the handler
|
|
641
|
+
* itself only needs to return 204.
|
|
642
|
+
*
|
|
643
|
+
* @param {import('koa').Context} ctx - Koa context
|
|
644
|
+
*/
|
|
645
|
+
heartbeat(ctx) {
|
|
646
|
+
ctx.status = HTTP_STATUS.NO_CONTENT;
|
|
490
647
|
}
|
|
491
648
|
});
|
|
492
649
|
const controllers = {
|
|
@@ -515,11 +672,11 @@ const policies = {
|
|
|
515
672
|
};
|
|
516
673
|
const admin = [
|
|
517
674
|
{
|
|
518
|
-
method: "
|
|
519
|
-
path: "/
|
|
520
|
-
handler: "adminSecurityController.
|
|
675
|
+
method: "POST",
|
|
676
|
+
path: "/heartbeat",
|
|
677
|
+
handler: "adminSecurityController.heartbeat",
|
|
521
678
|
config: {
|
|
522
|
-
|
|
679
|
+
// No policy: any authenticated admin may keep their session alive
|
|
523
680
|
}
|
|
524
681
|
},
|
|
525
682
|
{
|
|
@@ -558,74 +715,368 @@ const routes = {
|
|
|
558
715
|
let interval = null;
|
|
559
716
|
const autoLogoutChecker = ({ strapi: strapi2 }) => ({
|
|
560
717
|
/**
|
|
561
|
-
* Starts the auto-logout watcher interval.
|
|
562
|
-
*
|
|
563
|
-
* Runs every {@link CHECK_INTERVAL} ms, reads the configured `autoLogoutTime`
|
|
564
|
-
* from the database, and revokes tokens for sessions that have been idle
|
|
565
|
-
* beyond the threshold.
|
|
718
|
+
* Starts the auto-logout watcher interval. Idempotent — calling twice
|
|
719
|
+
* leaves the existing interval intact.
|
|
566
720
|
*/
|
|
567
721
|
startAutoLogoutWatcher() {
|
|
568
722
|
if (interval) {
|
|
569
723
|
strapi2.log.warn(`[${PLUGIN_ID}] AutoLogoutWatcher already running.`);
|
|
570
724
|
return;
|
|
571
725
|
}
|
|
726
|
+
const state2 = strapi2.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
572
727
|
interval = setInterval(async () => {
|
|
573
728
|
try {
|
|
574
|
-
|
|
729
|
+
const isLeader = await state2.acquireWatcherLease();
|
|
730
|
+
if (!isLeader) return;
|
|
731
|
+
await state2.pruneExpiredLocks();
|
|
575
732
|
const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
|
|
576
|
-
const
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
733
|
+
const idleThresholdMs = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
|
|
734
|
+
const idleSessions = await state2.listIdleSessions({ idleThresholdMs });
|
|
735
|
+
if (idleSessions.length === 0) return;
|
|
736
|
+
for (const session of idleSessions) {
|
|
737
|
+
await state2.revokeSession({ sessionId: session.sessionId });
|
|
738
|
+
if (strapi2.sessionManager && session.userId) {
|
|
739
|
+
await strapi2.sessionManager("admin").invalidateRefreshToken(String(session.userId)).catch(
|
|
740
|
+
(e) => strapi2.log.error(
|
|
741
|
+
`[${PLUGIN_ID}] Failed to invalidate DB session for ${session.email}:`,
|
|
742
|
+
e
|
|
743
|
+
)
|
|
587
744
|
);
|
|
588
745
|
}
|
|
746
|
+
const idleSeconds = Math.floor(
|
|
747
|
+
(Date.now() - new Date(session.lastActiveAt).getTime()) / MS_PER_SECOND
|
|
748
|
+
);
|
|
589
749
|
strapi2.log.info(
|
|
590
|
-
`[${PLUGIN_ID}] Auto-logged out admin "${email}" after ${
|
|
750
|
+
`[${PLUGIN_ID}] Auto-logged out admin "${session.email}" after ${idleSeconds}s of inactivity.`
|
|
591
751
|
);
|
|
592
752
|
}
|
|
593
753
|
} catch (err) {
|
|
594
|
-
strapi2.log.error(`[${PLUGIN_ID}] AutoLogoutWatcher failed:`, err);
|
|
754
|
+
strapi2.log.error(`[${PLUGIN_ID}] AutoLogoutWatcher tick failed:`, err);
|
|
595
755
|
}
|
|
596
756
|
}, CHECK_INTERVAL);
|
|
597
757
|
},
|
|
598
758
|
/**
|
|
599
|
-
* Stops the auto-logout watcher interval and releases the
|
|
600
|
-
|
|
601
|
-
stopAutoLogoutWatcher() {
|
|
602
|
-
if (interval) {
|
|
603
|
-
clearInterval(interval);
|
|
604
|
-
interval = null;
|
|
605
|
-
strapi2.log.info(`[${PLUGIN_ID}] AutoLogoutWatcher stopped.`);
|
|
606
|
-
}
|
|
607
|
-
},
|
|
608
|
-
/**
|
|
609
|
-
* Manually clears session activity and revoked state for a user.
|
|
759
|
+
* Stops the auto-logout watcher interval and releases the lease so another
|
|
760
|
+
* pod can pick it up immediately rather than waiting for TTL expiry.
|
|
610
761
|
*
|
|
611
|
-
* @
|
|
612
|
-
* @param {string} email - Admin email address
|
|
613
|
-
* @param {string} [reason='manual'] - Reason for clearing (used in log messages)
|
|
762
|
+
* @returns {Promise<void>}
|
|
614
763
|
*/
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
revokedTokenSet.delete(email);
|
|
623
|
-
strapi2.log.info(`[${PLUGIN_ID}] Revoked token cleared for ${email}`);
|
|
624
|
-
}
|
|
764
|
+
async stopAutoLogoutWatcher() {
|
|
765
|
+
if (!interval) return;
|
|
766
|
+
clearInterval(interval);
|
|
767
|
+
interval = null;
|
|
768
|
+
const state2 = strapi2.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
769
|
+
await state2.releaseWatcherLease();
|
|
770
|
+
strapi2.log.info(`[${PLUGIN_ID}] AutoLogoutWatcher stopped.`);
|
|
625
771
|
}
|
|
626
772
|
});
|
|
773
|
+
const lastDbWriteAt = /* @__PURE__ */ new Map();
|
|
774
|
+
const POD_ID = `${process.env.HOSTNAME ?? "pod"}-${randomUUID()}`;
|
|
775
|
+
const isUniqueConstraintError = (err) => {
|
|
776
|
+
if (!err) return false;
|
|
777
|
+
const msg = String(err.message ?? "");
|
|
778
|
+
return err.code === "23505" || err.code === "ER_DUP_ENTRY" || err.errno === 1062 || err.errno === 19 || /unique/i.test(msg) || /duplicate/i.test(msg);
|
|
779
|
+
};
|
|
780
|
+
const columnFor = (strapi2, uid, attribute) => {
|
|
781
|
+
try {
|
|
782
|
+
const metadata = strapi2.db.metadata.get(uid);
|
|
783
|
+
const attr = metadata?.attributes?.[attribute];
|
|
784
|
+
if (attr?.columnName) return attr.columnName;
|
|
785
|
+
} catch {
|
|
786
|
+
}
|
|
787
|
+
return attribute.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
788
|
+
};
|
|
789
|
+
const state = ({ strapi: strapi2 }) => {
|
|
790
|
+
const sessionUid = CONTENT_TYPES.ADMIN_SESSION;
|
|
791
|
+
const lockUid = CONTENT_TYPES.LOGIN_LOCK;
|
|
792
|
+
const leaseUid = CONTENT_TYPES.WATCHER_LEASE;
|
|
793
|
+
const tableOf = (uid) => strapi2.db.metadata.get(uid).tableName;
|
|
794
|
+
return {
|
|
795
|
+
/** Stable per-pod identifier; exposed for diagnostics. */
|
|
796
|
+
POD_ID,
|
|
797
|
+
/**
|
|
798
|
+
* Persists the user's last-active timestamp, write-coalesced to once
|
|
799
|
+
* per {@link ACTIVITY_FLUSH_INTERVAL_MS} per session.
|
|
800
|
+
*
|
|
801
|
+
* On first call for a session, performs an INSERT; subsequent calls
|
|
802
|
+
* UPDATE the existing row. A unique-constraint conflict (two pods
|
|
803
|
+
* inserting concurrently) falls back to UPDATE.
|
|
804
|
+
*
|
|
805
|
+
* @param {object} params
|
|
806
|
+
* @param {string} params.sessionId - JWT sessionId
|
|
807
|
+
* @param {number|string} params.userId - Admin user ID
|
|
808
|
+
* @param {string} params.email - Admin email
|
|
809
|
+
* @returns {Promise<void>}
|
|
810
|
+
*/
|
|
811
|
+
async touch({ sessionId, userId, email }) {
|
|
812
|
+
if (!sessionId || !userId || !email) return;
|
|
813
|
+
const now = Date.now();
|
|
814
|
+
const lastWrite = lastDbWriteAt.get(sessionId) ?? 0;
|
|
815
|
+
if (now - lastWrite < ACTIVITY_FLUSH_INTERVAL_MS) return;
|
|
816
|
+
lastDbWriteAt.set(sessionId, now);
|
|
817
|
+
const lastActiveAt = new Date(now);
|
|
818
|
+
try {
|
|
819
|
+
const existing = await strapi2.db.query(sessionUid).findOne({ where: { sessionId }, select: ["id"] });
|
|
820
|
+
if (existing) {
|
|
821
|
+
await strapi2.db.query(sessionUid).update({
|
|
822
|
+
where: { id: existing.id },
|
|
823
|
+
data: { lastActiveAt }
|
|
824
|
+
});
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
await strapi2.db.query(sessionUid).create({
|
|
828
|
+
data: { sessionId, userId, email, lastActiveAt }
|
|
829
|
+
});
|
|
830
|
+
} catch (err) {
|
|
831
|
+
if (isUniqueConstraintError(err)) {
|
|
832
|
+
await strapi2.db.query(sessionUid).update({ where: { sessionId }, data: { lastActiveAt } }).catch((e) => strapi2.log.error(`[${PLUGIN_ID}] touch fallback update failed:`, e));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
strapi2.log.error(`[${PLUGIN_ID}] touch failed for ${email}:`, err);
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
/**
|
|
839
|
+
* Returns true if the given session has been revoked.
|
|
840
|
+
*
|
|
841
|
+
* @param {string} sessionId - JWT sessionId
|
|
842
|
+
* @returns {Promise<boolean>}
|
|
843
|
+
*/
|
|
844
|
+
async isRevoked(sessionId) {
|
|
845
|
+
if (!sessionId) return false;
|
|
846
|
+
const row = await strapi2.db.query(sessionUid).findOne({ where: { sessionId }, select: ["revokedAt"] });
|
|
847
|
+
return Boolean(row?.revokedAt);
|
|
848
|
+
},
|
|
849
|
+
/**
|
|
850
|
+
* Marks a session as revoked.
|
|
851
|
+
*
|
|
852
|
+
* @param {object} params
|
|
853
|
+
* @param {string} params.sessionId - JWT sessionId
|
|
854
|
+
* @returns {Promise<void>}
|
|
855
|
+
*/
|
|
856
|
+
async revokeSession({ sessionId }) {
|
|
857
|
+
if (!sessionId) return;
|
|
858
|
+
lastDbWriteAt.delete(sessionId);
|
|
859
|
+
await strapi2.db.query(sessionUid).updateMany({ where: { sessionId }, data: { revokedAt: /* @__PURE__ */ new Date() } });
|
|
860
|
+
},
|
|
861
|
+
/**
|
|
862
|
+
* Marks all sessions belonging to the given email as revoked.
|
|
863
|
+
* Used by the auto-logout watcher and email-level revocation paths.
|
|
864
|
+
*
|
|
865
|
+
* @param {object} params
|
|
866
|
+
* @param {string} params.email - Admin email
|
|
867
|
+
* @returns {Promise<number>} Number of sessions revoked
|
|
868
|
+
*/
|
|
869
|
+
async revokeAllForEmail({ email }) {
|
|
870
|
+
if (!email) return 0;
|
|
871
|
+
const result = await strapi2.db.query(sessionUid).updateMany({
|
|
872
|
+
where: { email, revokedAt: { $null: true } },
|
|
873
|
+
data: { revokedAt: /* @__PURE__ */ new Date() }
|
|
874
|
+
});
|
|
875
|
+
return result?.count ?? 0;
|
|
876
|
+
},
|
|
877
|
+
/**
|
|
878
|
+
* Returns true if the email has an active (non-revoked, recent) session.
|
|
879
|
+
* Used by preventMultipleSessions.
|
|
880
|
+
*
|
|
881
|
+
* @param {object} params
|
|
882
|
+
* @param {string} params.email - Admin email
|
|
883
|
+
* @param {number} params.idleThresholdMs - Idle threshold in milliseconds
|
|
884
|
+
* @returns {Promise<boolean>}
|
|
885
|
+
*/
|
|
886
|
+
async hasActiveSession({ email, idleThresholdMs }) {
|
|
887
|
+
if (!email) return false;
|
|
888
|
+
const cutoff = new Date(Date.now() - idleThresholdMs);
|
|
889
|
+
const row = await strapi2.db.query(sessionUid).findOne({
|
|
890
|
+
where: {
|
|
891
|
+
email,
|
|
892
|
+
revokedAt: { $null: true },
|
|
893
|
+
lastActiveAt: { $gte: cutoff }
|
|
894
|
+
},
|
|
895
|
+
select: ["id"]
|
|
896
|
+
});
|
|
897
|
+
return Boolean(row);
|
|
898
|
+
},
|
|
899
|
+
/**
|
|
900
|
+
* Lists session rows that have been idle past the given threshold and
|
|
901
|
+
* are not yet revoked. Returns a small projection only.
|
|
902
|
+
*
|
|
903
|
+
* @param {object} params
|
|
904
|
+
* @param {number} params.idleThresholdMs - Idle threshold in milliseconds
|
|
905
|
+
* @returns {Promise<Array<{ id: number, sessionId: string, userId: number, email: string, lastActiveAt: Date }>>}
|
|
906
|
+
*/
|
|
907
|
+
async listIdleSessions({ idleThresholdMs }) {
|
|
908
|
+
const cutoff = new Date(Date.now() - idleThresholdMs);
|
|
909
|
+
return strapi2.db.query(sessionUid).findMany({
|
|
910
|
+
where: {
|
|
911
|
+
revokedAt: { $null: true },
|
|
912
|
+
lastActiveAt: { $lt: cutoff }
|
|
913
|
+
},
|
|
914
|
+
select: ["id", "sessionId", "userId", "email", "lastActiveAt"]
|
|
915
|
+
});
|
|
916
|
+
},
|
|
917
|
+
/**
|
|
918
|
+
* Atomically attempts to acquire a login lock for the given email.
|
|
919
|
+
* Returns true on success, false if another pod holds an unexpired lock.
|
|
920
|
+
*
|
|
921
|
+
* Uses `SELECT … FOR UPDATE` inside a transaction for cross-DB
|
|
922
|
+
* mutual exclusion (Postgres / MySQL / SQLite).
|
|
923
|
+
*
|
|
924
|
+
* @param {object} params
|
|
925
|
+
* @param {string} params.email - Admin email
|
|
926
|
+
* @returns {Promise<boolean>} True if the lock was acquired
|
|
927
|
+
*/
|
|
928
|
+
async acquireLoginLock({ email }) {
|
|
929
|
+
if (!email) return true;
|
|
930
|
+
const tableName = tableOf(lockUid);
|
|
931
|
+
const emailCol = columnFor(strapi2, lockUid, "email");
|
|
932
|
+
const lockedUntilCol = columnFor(strapi2, lockUid, "lockedUntil");
|
|
933
|
+
const documentIdCol = "document_id";
|
|
934
|
+
const createdAtCol = "created_at";
|
|
935
|
+
const updatedAtCol = "updated_at";
|
|
936
|
+
const now = /* @__PURE__ */ new Date();
|
|
937
|
+
const lockedUntil = new Date(now.getTime() + LOGIN_LOCK_TTL_MS);
|
|
938
|
+
const knex = strapi2.db.connection;
|
|
939
|
+
try {
|
|
940
|
+
return await knex.transaction(async (trx) => {
|
|
941
|
+
const row = await trx(tableName).where({ [emailCol]: email }).first().forUpdate();
|
|
942
|
+
if (!row) {
|
|
943
|
+
try {
|
|
944
|
+
await trx(tableName).insert({
|
|
945
|
+
[documentIdCol]: randomUUID(),
|
|
946
|
+
[emailCol]: email,
|
|
947
|
+
[lockedUntilCol]: lockedUntil,
|
|
948
|
+
[createdAtCol]: now,
|
|
949
|
+
[updatedAtCol]: now
|
|
950
|
+
});
|
|
951
|
+
return true;
|
|
952
|
+
} catch (err) {
|
|
953
|
+
if (isUniqueConstraintError(err)) return false;
|
|
954
|
+
throw err;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const heldUntil = row[lockedUntilCol] ? new Date(row[lockedUntilCol]) : null;
|
|
958
|
+
if (heldUntil && heldUntil > now) return false;
|
|
959
|
+
await trx(tableName).where({ id: row.id }).update({ [lockedUntilCol]: lockedUntil, [updatedAtCol]: now });
|
|
960
|
+
return true;
|
|
961
|
+
});
|
|
962
|
+
} catch (err) {
|
|
963
|
+
strapi2.log.error(`[${PLUGIN_ID}] acquireLoginLock failed for ${email}:`, err);
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
/**
|
|
968
|
+
* Releases the login lock for the given email. Idempotent.
|
|
969
|
+
*
|
|
970
|
+
* @param {object} params
|
|
971
|
+
* @param {string} params.email - Admin email
|
|
972
|
+
* @returns {Promise<void>}
|
|
973
|
+
*/
|
|
974
|
+
async releaseLoginLock({ email }) {
|
|
975
|
+
if (!email) return;
|
|
976
|
+
try {
|
|
977
|
+
await strapi2.db.query(lockUid).deleteMany({ where: { email } });
|
|
978
|
+
} catch (err) {
|
|
979
|
+
strapi2.log.error(`[${PLUGIN_ID}] releaseLoginLock failed for ${email}:`, err);
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
/**
|
|
983
|
+
* Removes login-lock rows whose `lockedUntil` has passed.
|
|
984
|
+
*
|
|
985
|
+
* @returns {Promise<number>} Number of rows deleted
|
|
986
|
+
*/
|
|
987
|
+
async pruneExpiredLocks() {
|
|
988
|
+
const result = await strapi2.db.query(lockUid).deleteMany({
|
|
989
|
+
where: { lockedUntil: { $lt: /* @__PURE__ */ new Date() } }
|
|
990
|
+
});
|
|
991
|
+
return result?.count ?? 0;
|
|
992
|
+
},
|
|
993
|
+
/**
|
|
994
|
+
* Atomically attempts to acquire (or renew) the auto-logout watcher lease.
|
|
995
|
+
* Only the holding pod runs the periodic watcher body cluster-wide.
|
|
996
|
+
*
|
|
997
|
+
* @returns {Promise<boolean>} True if this pod now holds the lease
|
|
998
|
+
*/
|
|
999
|
+
async acquireWatcherLease() {
|
|
1000
|
+
const tableName = tableOf(leaseUid);
|
|
1001
|
+
const nameCol = columnFor(strapi2, leaseUid, "name");
|
|
1002
|
+
const holderCol = columnFor(strapi2, leaseUid, "holder");
|
|
1003
|
+
const expiresAtCol = columnFor(strapi2, leaseUid, "expiresAt");
|
|
1004
|
+
const documentIdCol = "document_id";
|
|
1005
|
+
const createdAtCol = "created_at";
|
|
1006
|
+
const updatedAtCol = "updated_at";
|
|
1007
|
+
const now = /* @__PURE__ */ new Date();
|
|
1008
|
+
const expiresAt = new Date(now.getTime() + WATCHER_LEASE_TTL_MS);
|
|
1009
|
+
const knex = strapi2.db.connection;
|
|
1010
|
+
try {
|
|
1011
|
+
return await knex.transaction(async (trx) => {
|
|
1012
|
+
const row = await trx(tableName).where({ [nameCol]: WATCHER_LEASE_NAME }).first().forUpdate();
|
|
1013
|
+
if (!row) {
|
|
1014
|
+
try {
|
|
1015
|
+
await trx(tableName).insert({
|
|
1016
|
+
[documentIdCol]: randomUUID(),
|
|
1017
|
+
[nameCol]: WATCHER_LEASE_NAME,
|
|
1018
|
+
[holderCol]: POD_ID,
|
|
1019
|
+
[expiresAtCol]: expiresAt,
|
|
1020
|
+
[createdAtCol]: now,
|
|
1021
|
+
[updatedAtCol]: now
|
|
1022
|
+
});
|
|
1023
|
+
return true;
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
if (isUniqueConstraintError(err)) return false;
|
|
1026
|
+
throw err;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const heldUntil = row[expiresAtCol] ? new Date(row[expiresAtCol]) : null;
|
|
1030
|
+
const isOwner = row[holderCol] === POD_ID;
|
|
1031
|
+
const expired = !heldUntil || heldUntil <= now;
|
|
1032
|
+
if (!isOwner && !expired) return false;
|
|
1033
|
+
await trx(tableName).where({ id: row.id }).update({
|
|
1034
|
+
[holderCol]: POD_ID,
|
|
1035
|
+
[expiresAtCol]: expiresAt,
|
|
1036
|
+
[updatedAtCol]: now
|
|
1037
|
+
});
|
|
1038
|
+
return true;
|
|
1039
|
+
});
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
strapi2.log.error(`[${PLUGIN_ID}] acquireWatcherLease failed:`, err);
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
/**
|
|
1046
|
+
* Releases the watcher lease, but only if this pod currently holds it.
|
|
1047
|
+
*
|
|
1048
|
+
* @returns {Promise<void>}
|
|
1049
|
+
*/
|
|
1050
|
+
async releaseWatcherLease() {
|
|
1051
|
+
const tableName = tableOf(leaseUid);
|
|
1052
|
+
const nameCol = columnFor(strapi2, leaseUid, "name");
|
|
1053
|
+
const holderCol = columnFor(strapi2, leaseUid, "holder");
|
|
1054
|
+
const expiresAtCol = columnFor(strapi2, leaseUid, "expiresAt");
|
|
1055
|
+
const updatedAtCol = "updated_at";
|
|
1056
|
+
try {
|
|
1057
|
+
await strapi2.db.connection(tableName).where({ [nameCol]: WATCHER_LEASE_NAME, [holderCol]: POD_ID }).update({
|
|
1058
|
+
[holderCol]: "",
|
|
1059
|
+
[expiresAtCol]: /* @__PURE__ */ new Date(0),
|
|
1060
|
+
[updatedAtCol]: /* @__PURE__ */ new Date()
|
|
1061
|
+
});
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
strapi2.log.error(`[${PLUGIN_ID}] releaseWatcherLease failed:`, err);
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
/**
|
|
1067
|
+
* Test-only: clears the per-pod write-coalescing cache.
|
|
1068
|
+
* Exposed for unit tests so each test starts from a clean slate.
|
|
1069
|
+
*
|
|
1070
|
+
* @returns {void}
|
|
1071
|
+
*/
|
|
1072
|
+
_resetActivityCache() {
|
|
1073
|
+
lastDbWriteAt.clear();
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
};
|
|
627
1077
|
const services = {
|
|
628
|
-
autoLogoutChecker
|
|
1078
|
+
autoLogoutChecker,
|
|
1079
|
+
state
|
|
629
1080
|
};
|
|
630
1081
|
const index = {
|
|
631
1082
|
bootstrap,
|