@a83/orbiter-admin 0.3.12 → 0.3.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a83/orbiter-admin",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -29,6 +29,7 @@
29
29
  "@a83/orbiter-core": "^0.3.2",
30
30
  "@hono/node-server": "^1.14.4",
31
31
  "hono": "^4.7.11",
32
+ "nodemailer": "^8.0.10",
32
33
  "sharp": "^0.34.5"
33
34
  }
34
35
  }
@@ -116,6 +116,10 @@
116
116
  .btn-schedule:hover { background:var(--accent-bg); }
117
117
  .schedule-picker { display:none; gap:4px; margin-bottom:5px; }
118
118
  .schedule-picker.open { display:flex; }
119
+ .expiry-picker { display:none; gap:4px; margin-bottom:5px; }
120
+ .expiry-picker.open { display:flex; }
121
+ .btn-expiry { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); margin-bottom:5px; }
122
+ .btn-expiry:hover { border-color:var(--mid); color:var(--text); }
119
123
 
120
124
  /* Schema fields in sidebar */
121
125
  .field-input,.field-select { width:100%; background:var(--bg0); border:1px solid var(--line); padding:6px 8px; color:var(--heading); font-family:var(--mono); font-size:11px; outline:none; appearance:none; transition:border-color .15s; border-radius:var(--radius); box-sizing:border-box; }
@@ -399,11 +403,15 @@
399
403
  <button type="button" class="view-btn" id="vbtn-preview" onclick="setViewMode('preview')">Preview</button>
400
404
  </div>
401
405
  <a id="btn-preview-link" href="#" target="_blank" rel="noopener" title="Open preview" style="display:none;font-size:11px;color:var(--muted);text-decoration:none;padding:4px 8px;border:1px solid var(--line);border-radius:var(--radius);">↗ Preview</a>
406
+ <button class="search-trigger" id="search-btn" title="Search (⌘K)" style="background:none;border:none;color:var(--muted);cursor:pointer;padding:4px 8px;font-size:13px;">⌘K</button>
402
407
  <button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
403
408
  <span class="user" id="topbar-user"></span>
404
409
  <span class="logout" id="logout-btn">Sign out</span>
405
410
  </div>
406
411
  </header>
412
+ <div id="lock-banner" style="display:none;background:color-mix(in srgb,var(--gold) 12%,var(--bg2));border-bottom:1px solid color-mix(in srgb,var(--gold) 30%,transparent);padding:7px 20px;font-size:11px;color:var(--text);display:none;align-items:center;gap:8px;">
413
+ <span style="color:var(--gold);">⚠</span> <span id="lock-banner-msg"></span>
414
+ </div>
407
415
  <div class="editor-shell" id="editor-shell">
408
416
  <div class="editor-main" id="editor-main">
409
417
  <div id="saved-flash" style="display:none;" class="saved-flash">Entry saved</div>
@@ -512,6 +520,35 @@
512
520
 
513
521
  if (!colData) { location.replace('/collections.html'); }
514
522
 
523
+ // Entry locking — claim lock, warn if someone else is editing
524
+ let lockHeld = false;
525
+ let lockHeartbeat = null;
526
+ async function claimLock() {
527
+ if (IS_NEW) return;
528
+ const res = await fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'POST',credentials:'include'}).catch(()=>null);
529
+ if (!res) return;
530
+ if (res.status === 409) {
531
+ const j = await res.json().catch(()=>({}));
532
+ const banner = document.getElementById('lock-banner');
533
+ document.getElementById('lock-banner-msg').textContent = `${j.by ?? 'Someone'} is currently editing this entry. Your changes may conflict.`;
534
+ banner.style.display = 'flex';
535
+ lockHeld = false;
536
+ } else {
537
+ lockHeld = true;
538
+ }
539
+ }
540
+ async function releaseLock() {
541
+ if (!lockHeld || IS_NEW) return;
542
+ lockHeld = false;
543
+ await fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'DELETE',credentials:'include'}).catch(()=>{});
544
+ }
545
+ claimLock().then(()=>{
546
+ if (IS_NEW) return;
547
+ lockHeartbeat = setInterval(()=>fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'POST',credentials:'include'}).catch(()=>{}), 60_000);
548
+ });
549
+ window.addEventListener('beforeunload', () => { if (lockHeld) releaseLock(); });
550
+ document.addEventListener('visibilitychange', () => { if (document.hidden && lockHeld) releaseLock(); });
551
+
515
552
  // Breadcrumb
516
553
  document.getElementById('back-to-col').textContent = colData.label;
517
554
  document.getElementById('back-to-col').href = `/entries.html?col=${COLLECTION}&label=${encodeURIComponent(colData.label)}`;
@@ -563,8 +600,10 @@
563
600
  const panel = document.getElementById('meta-panel');
564
601
  const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
565
602
  const updatedAt = entryData?.updated_at;
566
- const publishAt = entryData?.publish_at ?? null;
567
- const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
603
+ const publishAt = entryData?.publish_at ?? null;
604
+ const unpublishAt = entryData?.unpublish_at ?? null;
605
+ const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
606
+ const unpublishAtInput = unpublishAt ? unpublishAt.replace(' ', 'T').slice(0, 16) : '';
568
607
 
569
608
  let fieldsHtml = '';
570
609
  const seoFields = {};
@@ -688,6 +727,13 @@
688
727
  <button type="button" id="btn-schedule-confirm" style="padding:5px 10px;background:var(--accent);border:none;color:var(--bg0);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
689
728
  </div>
690
729
  <button type="button" class="btn-schedule" id="btn-schedule">${status==='scheduled'?'Reschedule':'Schedule'}</button>
730
+ ${status==='published'?`
731
+ <div class="expiry-picker${unpublishAt?' open':''}" id="expiry-picker">
732
+ <input type="datetime-local" class="field-input" id="unpublish-at-input" value="${unpublishAtInput}" style="flex:1;font-size:10px;padding:5px 8px;" />
733
+ <button type="button" id="btn-expiry-confirm" style="padding:5px 10px;background:var(--bg3);border:1px solid var(--line);color:var(--text);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
734
+ </div>
735
+ <button type="button" class="btn-expiry" id="btn-expiry">${unpublishAt?'Change expiry':'Set expiry'}</button>
736
+ `:''}
691
737
  </div>
692
738
  <div class="meta-section">
693
739
  <div class="meta-label">Details</div>
@@ -790,6 +836,12 @@
790
836
  if (!val) { alert('Please select a date and time.'); return; }
791
837
  saveEntry('scheduled');
792
838
  });
839
+ document.getElementById('btn-expiry')?.addEventListener('click', () => {
840
+ document.getElementById('expiry-picker').classList.toggle('open');
841
+ });
842
+ document.getElementById('btn-expiry-confirm')?.addEventListener('click', () => {
843
+ saveEntry('published');
844
+ });
793
845
 
794
846
  // Version restore buttons
795
847
  panel.querySelectorAll('.v-restore-btn').forEach(btn => {
@@ -1006,6 +1058,12 @@
1006
1058
  if (!raw) { if (!isAutosave) alert('Please select a date and time.'); return; }
1007
1059
  publish_at = raw.replace('T', ' ') + ':00';
1008
1060
  }
1061
+ // Resolve unpublish_at for published entries with expiry
1062
+ let unpublish_at = null;
1063
+ if (status === 'published') {
1064
+ const raw = document.getElementById('unpublish-at-input')?.value;
1065
+ unpublish_at = raw ? raw.replace('T', ' ') + ':00' : null;
1066
+ }
1009
1067
 
1010
1068
  const title = document.getElementById('title-input').value;
1011
1069
  syncToHidden();
@@ -1029,6 +1087,22 @@
1029
1087
  }
1030
1088
  }
1031
1089
 
1090
+ // Required field validation (skip on autosave)
1091
+ if (!isAutosave) {
1092
+ const missing = extraFields
1093
+ .filter(([key, field]) => {
1094
+ if (!field.required) return false;
1095
+ const val = data[key];
1096
+ if (Array.isArray(val)) return val.length === 0;
1097
+ return val === '' || val === null || val === undefined;
1098
+ })
1099
+ .map(([key, field]) => field.label || key);
1100
+ if (missing.length) {
1101
+ alert(`Required field${missing.length > 1 ? 's' : ''} missing: ${missing.join(', ')}`);
1102
+ return;
1103
+ }
1104
+ }
1105
+
1032
1106
  if (IS_NEW && !currentSlug) {
1033
1107
  // Create new entry
1034
1108
  const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
@@ -1053,7 +1127,7 @@
1053
1127
  const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
1054
1128
  method:'PUT', credentials:'include',
1055
1129
  headers:{'Content-Type':'application/json'},
1056
- body: JSON.stringify({ slug, data, status, publish_at }),
1130
+ body: JSON.stringify({ slug, data, status, publish_at, unpublish_at }),
1057
1131
  });
1058
1132
  if (res.ok) {
1059
1133
  const json = await res.json();
@@ -1941,6 +2015,7 @@
1941
2015
  <input type="file" id="img-file-inp" accept="image/*" style="display:none">
1942
2016
 
1943
2017
  <script src="/sidebar.js"></script>
2018
+ <script src="/search.js"></script>
1944
2019
  <script src="/router.js"></script>
1945
2020
  </body>
1946
2021
  </html>
@@ -390,9 +390,11 @@
390
390
  ${total>0?'<div class="delete-warn">Adding fields is non-destructive. Removing fields hides existing data but does not delete it.</div>':''}
391
391
  <div id="edit-field-rows" class="field-rows"></div>
392
392
  <div class="editor-actions">
393
- <div style="display:flex;gap:10px;">
393
+ <div style="display:flex;gap:10px;flex-wrap:wrap;">
394
394
  <button class="btn btn-primary" id="btn-save-edit">Save</button>
395
395
  <button class="btn" id="btn-add-edit">+ Add field</button>
396
+ <button class="btn" id="btn-export-schema">↓ Export schema</button>
397
+ <label class="btn" style="cursor:pointer;">↑ Import schema<input type="file" id="import-schema-file" accept=".json" style="display:none"></label>
396
398
  </div>
397
399
  <button class="btn" id="btn-del-col" style="color:var(--red);border-color:var(--red);">Delete collection</button>
398
400
  </div>
@@ -401,6 +403,22 @@
401
403
  enableFieldDrag(rowsEl);
402
404
  Object.entries(schema).forEach(([k,f])=>addFieldRow(rowsEl,k,f.label??k,f.type??'string',!!f.required,(f.options??[]).join(', '),f.collection??'',f.multiple!==false));
403
405
  document.getElementById('btn-add-edit').addEventListener('click',()=>addFieldRow(rowsEl));
406
+ document.getElementById('btn-export-schema').addEventListener('click',()=>{
407
+ const blob=new Blob([JSON.stringify(serializeSchema(rowsEl),null,2)],{type:'application/json'});
408
+ const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`${col.id}-schema.json`; a.click(); URL.revokeObjectURL(a.href);
409
+ });
410
+ document.getElementById('import-schema-file').addEventListener('change',e=>{
411
+ const file=e.target.files[0]; if (!file) return;
412
+ const reader=new FileReader();
413
+ reader.onload=ev=>{
414
+ try {
415
+ const s=JSON.parse(ev.target.result);
416
+ rowsEl.innerHTML='';
417
+ Object.entries(s).forEach(([k,f])=>addFieldRow(rowsEl,k,f.label??k,f.type??'string',!!f.required,(f.options??[]).join(', '),f.collection??'',f.multiple!==false));
418
+ } catch { alert('Invalid schema JSON'); }
419
+ };
420
+ reader.readAsText(file); e.target.value='';
421
+ });
404
422
  // Load existing preview URL for this collection
405
423
  fetch(`/api/meta/preview_url~${col.id}`,{credentials:'include'}).then(r=>r.json()).then(d=>{
406
424
  const inp=document.getElementById('edit-preview-url'); if(inp)inp.value=d.value??'';
@@ -449,6 +449,42 @@
449
449
  </div>
450
450
  </div>
451
451
 
452
+ <div class="settings-group">
453
+ <div class="group-header">Email notifications</div>
454
+ <div class="setting-row">
455
+ <div><div class="setting-label">SMTP host</div><div class="setting-desc">e.g. smtp.fastmail.com, smtp.gmail.com</div></div>
456
+ <input class="input" name="email.smtp_host" value="${get('email.smtp_host')||''}" placeholder="smtp.example.com" />
457
+ </div>
458
+ <div class="setting-row">
459
+ <div><div class="setting-label">SMTP port</div><div class="setting-desc">Usually 587 (STARTTLS) or 465 (SSL)</div></div>
460
+ <input class="input" name="email.smtp_port" type="number" value="${get('email.smtp_port')||'587'}" placeholder="587" style="max-width:100px" />
461
+ </div>
462
+ <div class="setting-row">
463
+ <div><div class="setting-label">SMTP username</div></div>
464
+ <input class="input" name="email.smtp_user" value="${get('email.smtp_user')||''}" placeholder="you@example.com" />
465
+ </div>
466
+ <div class="setting-row">
467
+ <div><div class="setting-label">SMTP password</div></div>
468
+ <input class="input" type="password" name="email.smtp_pass" value="${get('email.smtp_pass')||''}" placeholder="••••••••" />
469
+ </div>
470
+ <div class="setting-row">
471
+ <div><div class="setting-label">From address</div><div class="setting-desc">Defaults to SMTP username if blank</div></div>
472
+ <input class="input" name="email.smtp_from" value="${get('email.smtp_from')||''}" placeholder="Orbiter &lt;noreply@example.com&gt;" />
473
+ </div>
474
+ <div class="setting-row">
475
+ <div><div class="setting-label">Notify to</div><div class="setting-desc">Recipient email address for notifications</div></div>
476
+ <input class="input" name="email.notify_to" value="${get('email.notify_to')||''}" placeholder="admin@example.com" />
477
+ </div>
478
+ <div class="setting-row">
479
+ <div><div class="setting-label">Notify on publish</div><div class="setting-desc">Send email when an entry is published</div></div>
480
+ <input type="checkbox" name="email.notify_publish" value="1" ${get('email.notify_publish')==='1'?'checked':''} style="accent-color:var(--accent);width:14px;height:14px;" />
481
+ </div>
482
+ <div class="setting-row">
483
+ <div><div class="setting-label">Notify on comment</div><div class="setting-desc">Send email when a new comment is posted</div></div>
484
+ <input type="checkbox" name="email.notify_comment" value="1" ${get('email.notify_comment')==='1'?'checked':''} style="accent-color:var(--accent);width:14px;height:14px;" />
485
+ </div>
486
+ </div>
487
+
452
488
  <div class="save-row">
453
489
  <button type="submit" class="btn-save">Save settings</button>
454
490
  </div>
@@ -668,9 +704,19 @@
668
704
  ['media.s3_access_key', fd.get('media.s3_access_key')],
669
705
  ['media.s3_secret_key', fd.get('media.s3_secret_key')],
670
706
  ['media.s3_public_url', fd.get('media.s3_public_url')],
671
- ['api.enabled', fd.get('api.enabled') ? '1' : '0'],
672
- ['api.token', fd.get('api.token')],
673
- ['preview.token', document.getElementById('preview-token-display').value || null],
707
+ ['api.enabled', fd.get('api.enabled') ? '1' : '0'],
708
+ ['api.token', fd.get('api.token')],
709
+ ['preview.token', document.getElementById('preview-token-display').value || null],
710
+ ['media.img_max_width', fd.get('media.img_max_width')],
711
+ ['media.img_quality', fd.get('media.img_quality')],
712
+ ['email.smtp_host', fd.get('email.smtp_host')],
713
+ ['email.smtp_port', fd.get('email.smtp_port')],
714
+ ['email.smtp_user', fd.get('email.smtp_user')],
715
+ ['email.smtp_pass', fd.get('email.smtp_pass')],
716
+ ['email.smtp_from', fd.get('email.smtp_from')],
717
+ ['email.notify_to', fd.get('email.notify_to')],
718
+ ['email.notify_publish', fd.get('email.notify_publish') ? '1' : '0'],
719
+ ['email.notify_comment', fd.get('email.notify_comment') ? '1' : '0'],
674
720
  ]);
675
721
  showBanner('site-banner','banner-ok','Settings saved');
676
722
  });
package/src/email.js ADDED
@@ -0,0 +1,50 @@
1
+ import nodemailer from 'nodemailer';
2
+ import { openPod } from '@a83/orbiter-core';
3
+
4
+ /**
5
+ * Send a notification email if SMTP is configured.
6
+ * @param {string} podPath
7
+ * @param {string} event — 'publish' | 'comment'
8
+ * @param {object} ctx — { collection, slug, username, body? }
9
+ */
10
+ export async function sendNotification(podPath, event, ctx = {}) {
11
+ let db;
12
+ try {
13
+ db = openPod(podPath);
14
+ const shouldSend = event === 'publish'
15
+ ? db.getMeta('email.notify_publish') === '1'
16
+ : db.getMeta('email.notify_comment') === '1';
17
+ if (!shouldSend) { db.close(); return; }
18
+
19
+ const host = db.getMeta('email.smtp_host') ?? '';
20
+ const port = parseInt(db.getMeta('email.smtp_port') ?? '587', 10);
21
+ const user = db.getMeta('email.smtp_user') ?? '';
22
+ const pass = db.getMeta('email.smtp_pass') ?? '';
23
+ const from = db.getMeta('email.smtp_from') || user;
24
+ const to = db.getMeta('email.notify_to') ?? '';
25
+ const site = db.getMeta('site.name') ?? 'Orbiter';
26
+ db.close();
27
+
28
+ if (!host || !to) return;
29
+
30
+ const transport = nodemailer.createTransport({
31
+ host, port,
32
+ secure: port === 465,
33
+ auth: user ? { user, pass } : undefined,
34
+ });
35
+
36
+ let subject, text;
37
+ if (event === 'publish') {
38
+ subject = `[${site}] Entry published: ${ctx.collection}/${ctx.slug}`;
39
+ text = `${ctx.username ?? 'Someone'} just published "${ctx.slug}" in the "${ctx.collection}" collection.`;
40
+ } else {
41
+ subject = `[${site}] New comment on ${ctx.collection}/${ctx.slug}`;
42
+ text = `${ctx.username ?? 'Someone'} commented on "${ctx.slug}":\n\n${ctx.body ?? ''}`;
43
+ }
44
+
45
+ await transport.sendMail({ from, to, subject, text });
46
+ } catch (e) {
47
+ console.warn('[email]', e.message);
48
+ db?.close();
49
+ }
50
+ }
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { openPod } from '@a83/orbiter-core';
3
+ import { sendNotification } from '../email.js';
3
4
 
4
5
  export const commentRoutes = new Hono();
5
6
 
@@ -25,6 +26,7 @@ commentRoutes.post('/:collectionId/entries/:slug/comments', async (c) => {
25
26
  const username = c.get('user')?.username ?? 'unknown';
26
27
  const id = db.createComment(entry.id, username, body.trim());
27
28
  db.close();
29
+ sendNotification(c.get('podPath'), 'comment', { collection: collectionId, slug, username, body: body.trim() }).catch(()=>{});
28
30
  return c.json({ ok: true, id }, 201);
29
31
  });
30
32
 
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { openPod } from '@a83/orbiter-core';
3
+ import { sendNotification } from '../email.js';
3
4
 
4
5
  export const entryRoutes = new Hono();
5
6
 
@@ -108,6 +109,7 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
108
109
 
109
110
  if (body.status === 'published' && before?.status !== 'published') {
110
111
  fireWebhook(c.get('podPath'));
112
+ sendNotification(c.get('podPath'), 'publish', { collection: collectionId, slug: body.slug ?? slug, username }).catch(()=>{});
111
113
  }
112
114
  return c.json(updated);
113
115
  });
@@ -274,14 +276,15 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
274
276
  // PATCH /api/collections/:id/entries/:slug/status
275
277
  entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
276
278
  const { collectionId, slug } = c.req.param();
277
- const { status, publish_at } = await c.req.json();
279
+ const { status, publish_at, unpublish_at } = await c.req.json();
278
280
  if (!['draft', 'published', 'scheduled'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
279
281
  if (status === 'scheduled' && !publish_at) return c.json({ error: 'publish_at required for scheduled status' }, 400);
280
282
  const db = openPod(c.get('podPath'));
281
283
  const entry = db.getEntry(collectionId, slug);
282
284
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
283
- const pa = status === 'scheduled' ? publish_at : null;
284
- db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa });
285
+ const pa = status === 'scheduled' ? publish_at : null;
286
+ const ua = status === 'published' ? (unpublish_at ?? null) : null;
287
+ db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa, unpublish_at: ua });
285
288
  const username = c.get('user')?.username ?? 'unknown';
286
289
  if (status === 'scheduled') db.logAudit(entry.id, username, 'schedule');
287
290
  db.close();
@@ -0,0 +1,48 @@
1
+ import { Hono } from 'hono';
2
+ import { openPod } from '@a83/orbiter-core';
3
+
4
+ export const lockRoutes = new Hono();
5
+
6
+ const STALE_MS = 90_000; // lock expires after 90 s without refresh
7
+
8
+ function lockKey(collection, slug) {
9
+ return `lock.${collection}.${slug}`;
10
+ }
11
+
12
+ function parseLock(val) {
13
+ if (!val) return null;
14
+ const [username, ts] = val.split('|');
15
+ const age = Date.now() - new Date(ts).getTime();
16
+ if (age > STALE_MS) return null; // stale — treat as free
17
+ return { username, ts };
18
+ }
19
+
20
+ // POST /api/locks/:collection/:slug — claim or refresh lock
21
+ lockRoutes.post('/:collection/:slug', (c) => {
22
+ const { collection, slug } = c.req.param();
23
+ const username = c.get('user')?.username ?? 'unknown';
24
+ const db = openPod(c.get('podPath'));
25
+ const key = lockKey(collection, slug);
26
+ const existing = parseLock(db.getMeta(key));
27
+
28
+ if (existing && existing.username !== username) {
29
+ db.close();
30
+ return c.json({ locked: true, by: existing.username }, 409);
31
+ }
32
+
33
+ db.setMeta(key, `${username}|${new Date().toISOString()}`);
34
+ db.close();
35
+ return c.json({ locked: false });
36
+ });
37
+
38
+ // DELETE /api/locks/:collection/:slug — release lock
39
+ lockRoutes.delete('/:collection/:slug', (c) => {
40
+ const { collection, slug } = c.req.param();
41
+ const username = c.get('user')?.username ?? 'unknown';
42
+ const db = openPod(c.get('podPath'));
43
+ const key = lockKey(collection, slug);
44
+ const existing = parseLock(db.getMeta(key));
45
+ if (existing && existing.username === username) db.setMeta(key, '');
46
+ db.close();
47
+ return c.json({ ok: true });
48
+ });
@@ -15,6 +15,8 @@ const ALLOWED_KEYS = [
15
15
  'dashboard.notes', 'dashboard.todos',
16
16
  'ui.theme',
17
17
  'format_version',
18
+ 'email.smtp_host', 'email.smtp_port', 'email.smtp_user', 'email.smtp_pass',
19
+ 'email.smtp_from', 'email.notify_publish', 'email.notify_comment', 'email.notify_to',
18
20
  ];
19
21
 
20
22
  const PREVIEW_URL_RE = /^preview_url\.[a-z0-9_-]+$/;
package/src/server.js CHANGED
@@ -23,6 +23,7 @@ import { githubRoutes } from './routes/github.js';
23
23
  import { infoRoutes } from './routes/info.js';
24
24
  import { importRoutes } from './routes/import.js';
25
25
  import { commentRoutes } from './routes/comments.js';
26
+ import { lockRoutes } from './routes/locks.js';
26
27
  import { requireAuth } from './middleware/auth.js';
27
28
 
28
29
  const { version: adminVersion } = JSON.parse(
@@ -74,6 +75,7 @@ export function createApp(podPath) {
74
75
  api.route('/import', importRoutes);
75
76
  api.route('/collections', commentRoutes);
76
77
  api.route('/', commentRoutes);
78
+ api.route('/locks', lockRoutes);
77
79
 
78
80
  app.route('/api', api);
79
81
 
@@ -101,17 +103,23 @@ serve({ fetch: createApp(POD_PATH).fetch, port: PORT }, () => {
101
103
  setInterval(() => {
102
104
  try {
103
105
  const db = openPod(POD_PATH);
104
- const due = db.getScheduledDue();
105
- if (!due.length) { db.close(); return; }
106
+ const due = db.getScheduledDue();
107
+ const expired = db.getExpiredDue();
108
+ if (!due.length && !expired.length) { db.close(); return; }
106
109
  const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
107
110
  for (const entry of due) {
108
111
  db.db.prepare("UPDATE _entries SET status = 'published', publish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
109
112
  db.logAudit(entry.id, 'scheduler', 'publish');
110
113
  }
114
+ for (const entry of expired) {
115
+ db.db.prepare("UPDATE _entries SET status = 'draft', unpublish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
116
+ db.logAudit(entry.id, 'scheduler', 'unpublish');
117
+ }
111
118
  const webhookUrl = db.getMeta('build.webhook_url') ?? '';
112
119
  if (webhookUrl) db.setMeta('build.last_triggered', new Date().toISOString());
113
120
  db.close();
114
- console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
121
+ if (due.length) console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
122
+ if (expired.length) console.log(`[scheduler] Unpublished ${expired.length} expired entr${expired.length === 1 ? 'y' : 'ies'}`);
115
123
  if (webhookUrl) fetch(webhookUrl, { method: 'POST' }).catch(() => {});
116
124
  } catch (e) {
117
125
  console.warn('[scheduler]', e.message);