@commonpub/layer 0.3.22 → 0.3.24

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 ADDED
@@ -0,0 +1,95 @@
1
+ # @commonpub/layer
2
+
3
+ Shared Nuxt 3 layer that provides the complete CommonPub application — pages, components, composables, server routes, plugins, and theme CSS. This is the primary way to build a CommonPub-powered site.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @commonpub/layer
9
+ ```
10
+
11
+ In your `nuxt.config.ts`:
12
+
13
+ ```ts
14
+ export default defineNuxtConfig({
15
+ extends: ['@commonpub/layer'],
16
+ });
17
+ ```
18
+
19
+ ## Configuration
20
+
21
+ Create a `commonpub.config.ts` in your project root:
22
+
23
+ ```ts
24
+ import { defineCommonPubConfig } from '@commonpub/config';
25
+
26
+ export default defineCommonPubConfig({
27
+ instance: {
28
+ name: 'My Community',
29
+ domain: 'example.com',
30
+ contentTypes: ['project', 'article', 'blog', 'explainer'],
31
+ },
32
+ features: {
33
+ content: true,
34
+ social: true,
35
+ hubs: true,
36
+ docs: true,
37
+ learning: true,
38
+ admin: true,
39
+ },
40
+ auth: {
41
+ methods: ['email'],
42
+ },
43
+ });
44
+ ```
45
+
46
+ Create `server/utils/config.ts` to load the config on the server side. See `apps/shell/server/utils/config.ts` for a complete example with environment variable overrides.
47
+
48
+ ## What's Included
49
+
50
+ ### Pages (20+ routes)
51
+
52
+ Content CRUD, hub feeds, learning paths, docs sites, admin panel, federation management, user profiles, messaging, notifications, search, and more.
53
+
54
+ ### Components (30+)
55
+
56
+ Content editor (`CpubEditor`), content cards, author rows, comment sections, engagement bars, federation UI, notification items, message threads, and more.
57
+
58
+ ### Composables (19)
59
+
60
+ `useAuth`, `useFeatures`, `useBlockEditor`, `useContentSave`, `useEngagement`, `useFederation`, `useMessages`, `useNotifications`, `useTheme`, and more.
61
+
62
+ ### Server
63
+
64
+ API routes for all CommonPub features, auth middleware, federation endpoints, and Nitro plugins.
65
+
66
+ ### Theme
67
+
68
+ CSS custom properties with 4 built-in themes (base, deepwood, hackbuild, deveco). Override with your own `theme.css`.
69
+
70
+ ## Customization
71
+
72
+ The layer is designed to be extended:
73
+
74
+ - **Override components**: Place a component with the same name in your app's `components/` directory
75
+ - **Override pages**: Place a page at the same route path in your app's `pages/` directory
76
+ - **Custom theme**: Add CSS custom property overrides in your app's assets
77
+ - **Feature flags**: Enable/disable features via `commonpub.config.ts`
78
+
79
+ ## Dependencies
80
+
81
+ This layer depends on all `@commonpub/*` packages:
82
+
83
+ - `@commonpub/schema` — Database tables and validators
84
+ - `@commonpub/config` — Configuration and feature flags
85
+ - `@commonpub/server` — Business logic
86
+ - `@commonpub/auth` — Authentication
87
+ - `@commonpub/protocol` — ActivityPub federation
88
+ - `@commonpub/ui` — Headless UI components
89
+ - `@commonpub/editor` — TipTap block editor
90
+ - `@commonpub/docs` — Documentation module
91
+ - `@commonpub/learning` — Learning path engine
92
+
93
+ ## License
94
+
95
+ AGPL-3.0-or-later
@@ -161,7 +161,7 @@ async function deleteComment(id: string): Promise<void> {
161
161
  {{ new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
162
162
  </time>
163
163
  </div>
164
- <p class="cpub-comment-text">{{ comment.content }}</p>
164
+ <p class="cpub-comment-text"><MentionText :text="comment.content" /></p>
165
165
  <button
166
166
  v-if="user?.id === comment.author?.id"
167
167
  class="cpub-comment-delete"
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- defineProps<{
2
+ const props = defineProps<{
3
3
  messages: Array<{
4
4
  id: string;
5
5
  senderId: string;
@@ -17,6 +17,19 @@ const emit = defineEmits<{
17
17
  }>();
18
18
 
19
19
  const newMessage = ref('');
20
+ const messagesContainer = ref<HTMLElement | null>(null);
21
+
22
+ function scrollToBottom(): void {
23
+ nextTick(() => {
24
+ if (messagesContainer.value) {
25
+ messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
26
+ }
27
+ });
28
+ }
29
+
30
+ // Auto-scroll when messages change (new message arrives or initial load)
31
+ watch(() => props.messages.length, scrollToBottom);
32
+ onMounted(scrollToBottom);
20
33
 
21
34
  function handleSend(): void {
22
35
  if (!newMessage.value.trim()) return;
@@ -27,7 +40,7 @@ function handleSend(): void {
27
40
 
28
41
  <template>
29
42
  <div class="cpub-thread">
30
- <div class="cpub-thread-messages">
43
+ <div ref="messagesContainer" class="cpub-thread-messages">
31
44
  <div
32
45
  v-for="msg in messages"
33
46
  :key="msg.id"
@@ -11,6 +11,7 @@ export interface FeatureFlags {
11
11
  explainers: boolean;
12
12
  federation: boolean;
13
13
  admin: boolean;
14
+ emailNotifications: boolean;
14
15
  }
15
16
 
16
17
  export function useFeatures() {
@@ -29,5 +30,6 @@ export function useFeatures() {
29
30
  explainers: computed(() => flags.explainers),
30
31
  federation: computed(() => flags.federation),
31
32
  admin: computed(() => flags.admin),
33
+ emailNotifications: computed(() => flags.emailNotifications),
32
34
  };
33
35
  }
package/nuxt.config.ts CHANGED
@@ -67,6 +67,7 @@ export default defineNuxtConfig({
67
67
  federation: false,
68
68
  federateHubs: false,
69
69
  admin: false,
70
+ emailNotifications: false,
70
71
  },
71
72
  contentTypes: 'project,article,blog,explainer',
72
73
  contestCreation: 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -45,14 +45,14 @@
45
45
  "vue-router": "^4.3.0",
46
46
  "zod": "^4.3.6",
47
47
  "@commonpub/auth": "0.5.0",
48
- "@commonpub/config": "0.7.0",
49
48
  "@commonpub/docs": "0.5.2",
49
+ "@commonpub/protocol": "0.9.5",
50
50
  "@commonpub/editor": "0.5.0",
51
- "@commonpub/learning": "0.5.0",
52
- "@commonpub/protocol": "0.9.4",
53
51
  "@commonpub/schema": "0.8.12",
54
- "@commonpub/server": "2.14.0",
55
- "@commonpub/ui": "0.7.1"
52
+ "@commonpub/server": "2.15.0",
53
+ "@commonpub/config": "0.7.1",
54
+ "@commonpub/ui": "0.7.1",
55
+ "@commonpub/learning": "0.5.0"
56
56
  },
57
57
  "scripts": {}
58
58
  }
@@ -235,7 +235,7 @@ useSeoMeta({
235
235
  <span class="cpub-post-sep">&middot;</span>
236
236
  <time class="cpub-post-time">{{ formatDate(reply.createdAt) }}</time>
237
237
  </div>
238
- <div class="cpub-reply-content">{{ reply.content }}</div>
238
+ <div class="cpub-reply-content"><MentionText :text="reply.content" /></div>
239
239
  <div class="cpub-reply-actions">
240
240
  <button v-if="isAuthenticated && hub?.currentUserRole && !post.isLocked" class="cpub-reply-btn" @click="replyingTo = reply.id; replyContent = `@${reply.author?.username} `">
241
241
  <i class="fa-solid fa-reply"></i> Reply
@@ -254,7 +254,7 @@ useSeoMeta({
254
254
  <span class="cpub-post-sep">&middot;</span>
255
255
  <time class="cpub-post-time">{{ formatDate(child.createdAt) }}</time>
256
256
  </div>
257
- <div class="cpub-reply-content">{{ child.content }}</div>
257
+ <div class="cpub-reply-content"><MentionText :text="child.content" /></div>
258
258
  </div>
259
259
  </div>
260
260
  </div>
@@ -34,6 +34,36 @@ const newRecipients = ref<string[]>([]);
34
34
  const newMessage = ref('');
35
35
 
36
36
  const msgError = ref('');
37
+ const dialogRef = ref<HTMLElement | null>(null);
38
+ const triggerRef = ref<HTMLElement | null>(null);
39
+
40
+ // WCAG focus trap: trap Tab inside dialog, restore focus on close
41
+ watch(showNewDialog, async (open) => {
42
+ if (open) {
43
+ await nextTick();
44
+ const firstInput = dialogRef.value?.querySelector<HTMLElement>('input, textarea, button:not([disabled])');
45
+ firstInput?.focus();
46
+ } else {
47
+ triggerRef.value?.focus();
48
+ }
49
+ });
50
+
51
+ function handleDialogKeydown(e: KeyboardEvent): void {
52
+ if (e.key !== 'Tab' || !dialogRef.value) return;
53
+ const focusable = dialogRef.value.querySelectorAll<HTMLElement>(
54
+ 'input:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])',
55
+ );
56
+ if (focusable.length === 0) return;
57
+ const first = focusable[0]!;
58
+ const last = focusable[focusable.length - 1]!;
59
+ if (e.shiftKey && document.activeElement === first) {
60
+ e.preventDefault();
61
+ last.focus();
62
+ } else if (!e.shiftKey && document.activeElement === last) {
63
+ e.preventDefault();
64
+ first.focus();
65
+ }
66
+ }
37
67
 
38
68
  function addRecipient(): void {
39
69
  const val = newRecipientInput.value.trim();
@@ -79,14 +109,14 @@ async function startConversation(): Promise<void> {
79
109
  <div class="cpub-messages-page">
80
110
  <div class="cpub-messages-header">
81
111
  <h1 class="cpub-section-title-lg">Messages</h1>
82
- <button class="cpub-btn cpub-btn-sm cpub-btn-primary" @click="showNewDialog = true">
112
+ <button ref="triggerRef" class="cpub-btn cpub-btn-sm cpub-btn-primary" @click="showNewDialog = true">
83
113
  <i class="fa-solid fa-pen"></i> New Message
84
114
  </button>
85
115
  </div>
86
116
 
87
117
  <!-- New conversation dialog -->
88
- <div v-if="showNewDialog" class="cpub-new-msg-overlay" @click.self="showNewDialog = false" @keydown.escape="showNewDialog = false">
89
- <div class="cpub-new-msg-dialog" role="dialog" aria-label="New message">
118
+ <div v-if="showNewDialog" class="cpub-new-msg-overlay" @click.self="showNewDialog = false" @keydown.escape="showNewDialog = false" @keydown="handleDialogKeydown">
119
+ <div ref="dialogRef" class="cpub-new-msg-dialog" role="dialog" aria-label="New message" aria-modal="true">
90
120
  <div class="cpub-new-msg-header">
91
121
  <h2 class="cpub-new-msg-title">New Conversation</h2>
92
122
  <button class="cpub-new-msg-close" @click="showNewDialog = false" aria-label="Close">
@@ -39,6 +39,22 @@ const socialLinks = ref({
39
39
  });
40
40
  const experience = ref<Array<{ id: string; title: string; company: string; startDate: string; endDate: string; description: string }>>([]);
41
41
 
42
+ const emailNotifications = ref<{
43
+ digest: 'daily' | 'weekly' | 'none';
44
+ likes: boolean;
45
+ comments: boolean;
46
+ follows: boolean;
47
+ mentions: boolean;
48
+ }>({
49
+ digest: 'none',
50
+ likes: false,
51
+ comments: false,
52
+ follows: false,
53
+ mentions: false,
54
+ });
55
+
56
+ const { emailNotifications: emailNotificationsEnabled } = useFeatures();
57
+
42
58
  const avatarInput = ref<HTMLInputElement | null>(null);
43
59
  const bannerInput = ref<HTMLInputElement | null>(null);
44
60
 
@@ -73,12 +89,22 @@ if (profile.value) {
73
89
  if (Array.isArray(profileRecord.experience)) {
74
90
  experience.value = (profileRecord.experience as Array<Record<string, unknown>>).map((e) => ({ ...e }) as typeof experience.value[number]);
75
91
  }
92
+ if (profileRecord.emailNotifications && typeof profileRecord.emailNotifications === 'object') {
93
+ const en = profileRecord.emailNotifications as Record<string, unknown>;
94
+ emailNotifications.value = {
95
+ digest: (['daily', 'weekly', 'none'].includes(en.digest as string) ? en.digest : 'none') as 'daily' | 'weekly' | 'none',
96
+ likes: en.likes === true,
97
+ comments: en.comments === true,
98
+ follows: en.follows === true,
99
+ mentions: en.mentions === true,
100
+ };
101
+ }
76
102
  }
77
103
 
78
104
  // Watch for form changes AFTER initial data is loaded (nextTick avoids false positive)
79
105
  onMounted(() => {
80
106
  nextTick(() => {
81
- watch([form, skills, socialLinks, experience], () => { isDirty.value = true; }, { deep: true });
107
+ watch([form, skills, socialLinks, experience, emailNotifications], () => { isDirty.value = true; }, { deep: true });
82
108
  });
83
109
  });
84
110
 
@@ -147,6 +173,7 @@ async function handleSave(): Promise<void> {
147
173
  skills: skills.value.filter((s) => s.name.trim()),
148
174
  socialLinks: socialLinks.value,
149
175
  experience: experience.value.filter((e) => e.title.trim()),
176
+ ...(emailNotificationsEnabled.value ? { emailNotifications: emailNotifications.value } : {}),
150
177
  },
151
178
  });
152
179
  toast.success('Profile updated');
@@ -490,6 +517,76 @@ async function handleSave(): Promise<void> {
490
517
  </button>
491
518
  </div>
492
519
 
520
+ <!-- Email Notifications (gated behind feature flag) -->
521
+ <div v-if="emailNotificationsEnabled" class="cpub-form-section">
522
+ <span class="cpub-form-section-label">Email Notifications</span>
523
+
524
+ <div class="cpub-form-group">
525
+ <label for="digest-mode" class="cpub-form-label">Digest Mode</label>
526
+ <select
527
+ id="digest-mode"
528
+ v-model="emailNotifications.digest"
529
+ class="cpub-select"
530
+ >
531
+ <option value="none">Off (instant emails only)</option>
532
+ <option value="daily">Daily digest</option>
533
+ <option value="weekly">Weekly digest</option>
534
+ </select>
535
+ <span class="cpub-form-hint">
536
+ {{ emailNotifications.digest === 'none'
537
+ ? 'Instant emails are sent for each enabled type below.'
538
+ : `A ${emailNotifications.digest} email summarizing your unread notifications.` }}
539
+ </span>
540
+ </div>
541
+
542
+ <fieldset class="cpub-notification-toggles" :disabled="emailNotifications.digest !== 'none'">
543
+ <legend class="cpub-form-label cpub-toggle-legend">Instant Email Types</legend>
544
+ <span v-if="emailNotifications.digest !== 'none'" class="cpub-form-hint cpub-toggle-hint">
545
+ Individual emails are disabled when digest mode is active.
546
+ </span>
547
+
548
+ <label class="cpub-toggle-row" for="notif-likes">
549
+ <input
550
+ id="notif-likes"
551
+ v-model="emailNotifications.likes"
552
+ type="checkbox"
553
+ class="cpub-checkbox"
554
+ />
555
+ <span>Likes</span>
556
+ </label>
557
+
558
+ <label class="cpub-toggle-row" for="notif-comments">
559
+ <input
560
+ id="notif-comments"
561
+ v-model="emailNotifications.comments"
562
+ type="checkbox"
563
+ class="cpub-checkbox"
564
+ />
565
+ <span>Comments</span>
566
+ </label>
567
+
568
+ <label class="cpub-toggle-row" for="notif-follows">
569
+ <input
570
+ id="notif-follows"
571
+ v-model="emailNotifications.follows"
572
+ type="checkbox"
573
+ class="cpub-checkbox"
574
+ />
575
+ <span>New followers</span>
576
+ </label>
577
+
578
+ <label class="cpub-toggle-row" for="notif-mentions">
579
+ <input
580
+ id="notif-mentions"
581
+ v-model="emailNotifications.mentions"
582
+ type="checkbox"
583
+ class="cpub-checkbox"
584
+ />
585
+ <span>@mentions</span>
586
+ </label>
587
+ </fieldset>
588
+ </div>
589
+
493
590
  <!-- Actions -->
494
591
  <div class="cpub-form-actions">
495
592
  <button type="submit" class="cpub-save-btn" :disabled="saving">
@@ -792,6 +889,61 @@ async function handleSave(): Promise<void> {
792
889
  gap: var(--space-4);
793
890
  }
794
891
 
892
+ /* ─── Email notification toggles ─── */
893
+ .cpub-select {
894
+ display: block;
895
+ width: 100%;
896
+ padding: var(--space-2) var(--space-3);
897
+ background: var(--surface);
898
+ color: var(--text);
899
+ border: var(--border-width-default) solid var(--border2);
900
+ font-size: var(--text-sm);
901
+ font-family: var(--font-sans);
902
+ appearance: none;
903
+ }
904
+
905
+ .cpub-select:focus-visible {
906
+ outline: 2px solid var(--accent);
907
+ outline-offset: 1px;
908
+ }
909
+
910
+ .cpub-notification-toggles {
911
+ border: none;
912
+ padding: 0;
913
+ margin: var(--space-3) 0 0;
914
+ }
915
+
916
+ .cpub-notification-toggles:disabled {
917
+ opacity: 0.5;
918
+ }
919
+
920
+ .cpub-toggle-legend {
921
+ margin-bottom: var(--space-2);
922
+ }
923
+
924
+ .cpub-toggle-hint {
925
+ display: block;
926
+ margin-bottom: var(--space-2);
927
+ }
928
+
929
+ .cpub-toggle-row {
930
+ display: flex;
931
+ align-items: center;
932
+ gap: var(--space-3);
933
+ padding: var(--space-2) 0;
934
+ cursor: pointer;
935
+ font-size: var(--text-sm);
936
+ color: var(--text);
937
+ }
938
+
939
+ .cpub-checkbox {
940
+ width: 16px;
941
+ height: 16px;
942
+ accent-color: var(--accent);
943
+ cursor: pointer;
944
+ flex-shrink: 0;
945
+ }
946
+
795
947
  /* ─── Form actions ─── */
796
948
  .cpub-form-actions {
797
949
  display: flex;
@@ -10,17 +10,24 @@ export default defineEventHandler(async (event) => {
10
10
  const user = requireAuth(event);
11
11
  const { url, purpose } = await parseBody(event, schema);
12
12
 
13
- // SSRF protection — block private IPs
13
+ // SSRF protection — block private/internal IPs
14
14
  const parsed = new URL(url);
15
- const hostname = parsed.hostname;
15
+ const hostname = parsed.hostname.toLowerCase();
16
+ const h = hostname.replace(/^\[|\]$/g, '');
16
17
  if (
17
- hostname === 'localhost' ||
18
- hostname === '127.0.0.1' ||
19
- hostname === '::1' ||
20
- hostname.startsWith('10.') ||
21
- hostname.startsWith('192.168.') ||
22
- hostname.match(/^172\.(1[6-9]|2\d|3[01])\./) ||
23
- hostname === '169.254.169.254' // AWS metadata
18
+ h === 'localhost' ||
19
+ h === 'localhost.localdomain' ||
20
+ h === 'metadata.google.internal' ||
21
+ h.endsWith('.local') ||
22
+ /^127\./.test(h) ||
23
+ /^10\./.test(h) ||
24
+ /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
25
+ /^192\.168\./.test(h) ||
26
+ /^169\.254\./.test(h) ||
27
+ /^0\./.test(h) ||
28
+ h === '::1' ||
29
+ /^f[cd]/i.test(h) ||
30
+ /^fe80/i.test(h)
24
31
  ) {
25
32
  throw createError({ statusCode: 400, statusMessage: 'Cannot fetch from private/local addresses' });
26
33
  }
@@ -29,15 +29,22 @@ export default defineEventHandler(async (event) => {
29
29
  }
30
30
 
31
31
  // Block localhost/private IPs (SSRF prevention)
32
- const hostname = parsed.hostname;
32
+ const hostname = parsed.hostname.toLowerCase();
33
+ const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
33
34
  if (
34
- hostname === 'localhost' ||
35
- hostname === '127.0.0.1' ||
36
- hostname === '::1' ||
37
- hostname.startsWith('10.') ||
38
- hostname.startsWith('172.') ||
39
- hostname.startsWith('192.168.') ||
40
- hostname.endsWith('.local')
35
+ h === 'localhost' ||
36
+ h === 'localhost.localdomain' ||
37
+ h === 'metadata.google.internal' ||
38
+ h.endsWith('.local') ||
39
+ /^127\./.test(h) ||
40
+ /^10\./.test(h) ||
41
+ /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
42
+ /^192\.168\./.test(h) ||
43
+ /^169\.254\./.test(h) ||
44
+ /^0\./.test(h) ||
45
+ h === '::1' ||
46
+ /^f[cd]/i.test(h) ||
47
+ /^fe80/i.test(h)
41
48
  ) {
42
49
  throw createError({ statusCode: 403, statusMessage: 'Private addresses not allowed' });
43
50
  }
@@ -30,8 +30,12 @@ export default defineEventHandler(async (event) => {
30
30
  controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications, messages })}\n\n`));
31
31
  }
32
32
 
33
- // Send initial counts
34
- await sendCounts();
33
+ // Send initial counts — if DB is unavailable, send zeros and let polling retry
34
+ try {
35
+ await sendCounts();
36
+ } catch {
37
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications: 0, messages: 0 })}\n\n`));
38
+ }
35
39
 
36
40
  // Poll every 10 seconds
37
41
  const interval = setInterval(async () => {
@@ -1,45 +1,10 @@
1
1
  // Nitro middleware for authentication using @commonpub/auth
2
2
  import { createAuthMiddleware, type AuthLocals } from '@commonpub/auth';
3
3
  import { createAuth } from '@commonpub/auth';
4
- import { ConsoleEmailAdapter, SmtpEmailAdapter, ResendEmailAdapter, emailTemplates } from '@commonpub/server';
5
- import type { EmailAdapter } from '@commonpub/server';
4
+ import { emailTemplates } from '@commonpub/server';
6
5
 
7
6
  let authMiddleware: ReturnType<typeof createAuthMiddleware> | null = null;
8
7
 
9
- function createEmailAdapter(): EmailAdapter {
10
- const runtimeConfig = useRuntimeConfig();
11
- const adapter = (runtimeConfig.emailAdapter as string) || 'console';
12
-
13
- if (adapter === 'smtp') {
14
- const host = runtimeConfig.smtpHost as string;
15
- const port = parseInt(runtimeConfig.smtpPort as string, 10) || 587;
16
- const user = runtimeConfig.smtpUser as string;
17
- const pass = runtimeConfig.smtpPass as string;
18
- const from = runtimeConfig.smtpFrom as string;
19
-
20
- if (!host || !user || !pass || !from) {
21
- console.warn('[email] SMTP configured but missing credentials — falling back to console');
22
- return new ConsoleEmailAdapter();
23
- }
24
-
25
- return new SmtpEmailAdapter({ host, port, user, pass, from });
26
- }
27
-
28
- if (adapter === 'resend') {
29
- const apiKey = runtimeConfig.resendApiKey as string;
30
- const from = runtimeConfig.resendFrom as string;
31
-
32
- if (!apiKey || !from) {
33
- console.warn('[email] Resend configured but missing API key or from address — falling back to console');
34
- return new ConsoleEmailAdapter();
35
- }
36
-
37
- return new ResendEmailAdapter({ apiKey, from });
38
- }
39
-
40
- return new ConsoleEmailAdapter();
41
- }
42
-
43
8
  function getAuthMiddleware(): ReturnType<typeof createAuthMiddleware> {
44
9
  if (authMiddleware) return authMiddleware;
45
10
 
@@ -49,7 +14,7 @@ function getAuthMiddleware(): ReturnType<typeof createAuthMiddleware> {
49
14
  const siteUrl = (runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`;
50
15
  const siteName = config.instance.name || 'CommonPub';
51
16
 
52
- const emailAdapter = createEmailAdapter();
17
+ const emailAdapter = useEmailAdapter();
53
18
 
54
19
  // In dev, trust any localhost origin so port changes don't break auth
55
20
  const trustedOrigins = process.env.NODE_ENV !== 'production'
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Federation hub sync worker.
3
+ * Periodically refreshes metadata and optionally backfills posts for accepted federated hubs.
4
+ * Gated on features.federateHubs. Configurable via federation.hubSyncIntervalMs.
5
+ */
6
+ import {
7
+ refreshFederatedHubMetadata,
8
+ backfillHubFromOutbox,
9
+ } from '@commonpub/server';
10
+ import { federatedHubs } from '@commonpub/schema';
11
+ import { eq, and, or, lt, isNull } from 'drizzle-orm';
12
+
13
+ const MAX_HUBS_PER_CYCLE = 5;
14
+ const STAGGER_DELAY_MS = 2_000;
15
+
16
+ export default defineNitroPlugin((nitro) => {
17
+ if (process.env.NODE_ENV === 'test') return;
18
+
19
+ let interval: ReturnType<typeof setInterval> | null = null;
20
+
21
+ const startupTimer = setTimeout(() => {
22
+ try {
23
+ const config = useConfig();
24
+ if (!config.features.federateHubs) {
25
+ console.log('[hub-sync] Hub federation disabled — sync worker not started');
26
+ return;
27
+ }
28
+
29
+ const runtimeConfig = useRuntimeConfig();
30
+ const siteUrl = (runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`;
31
+ const domain = siteUrl.replace(/^https?:\/\//, '').replace(/[:/].*$/, '');
32
+
33
+ const fedConfig = config.federation ?? {};
34
+ const intervalMs = fedConfig.hubSyncIntervalMs ?? 3_600_000;
35
+ const backfillOnSync = fedConfig.backfillOnMirrorAccept ?? false;
36
+
37
+ console.log(`[hub-sync] Hub sync worker started (domain: ${domain}, interval: ${intervalMs}ms, backfill: ${backfillOnSync})`);
38
+
39
+ // Run first sync after a brief delay to avoid startup contention
40
+ runSync(domain, intervalMs, backfillOnSync);
41
+
42
+ interval = setInterval(() => {
43
+ runSync(domain, intervalMs, backfillOnSync).catch((err) => {
44
+ console.error('[hub-sync] Sync worker unexpected error:', err instanceof Error ? err.message : err);
45
+ });
46
+ }, intervalMs);
47
+ } catch (err) {
48
+ console.error('[hub-sync] Failed to start:', err instanceof Error ? err.message : err);
49
+ }
50
+ }, 10_000); // 10s startup delay (longer than delivery worker to avoid contention)
51
+
52
+ async function runSync(domain: string, intervalMs: number, backfillOnSync: boolean): Promise<void> {
53
+ try {
54
+ const db = useDB();
55
+ const now = new Date();
56
+ const staleThreshold = new Date(now.getTime() - intervalMs);
57
+
58
+ // Find accepted, non-hidden hubs where lastSyncAt is older than the interval or null
59
+ const staleHubs = await db
60
+ .select({
61
+ id: federatedHubs.id,
62
+ actorUri: federatedHubs.actorUri,
63
+ name: federatedHubs.name,
64
+ lastSyncAt: federatedHubs.lastSyncAt,
65
+ })
66
+ .from(federatedHubs)
67
+ .where(and(
68
+ eq(federatedHubs.status, 'accepted'),
69
+ eq(federatedHubs.isHidden, false),
70
+ or(
71
+ isNull(federatedHubs.lastSyncAt),
72
+ lt(federatedHubs.lastSyncAt, staleThreshold),
73
+ ),
74
+ ))
75
+ .limit(MAX_HUBS_PER_CYCLE);
76
+
77
+ if (staleHubs.length === 0) return;
78
+
79
+ console.log(`[hub-sync] Found ${staleHubs.length} stale hub(s) to sync`);
80
+
81
+ for (const hub of staleHubs) {
82
+ try {
83
+ // Refresh metadata (name, description, icon, member count)
84
+ await refreshFederatedHubMetadata(db, hub.id, hub.actorUri);
85
+
86
+ // Optionally backfill new posts from outbox
87
+ if (backfillOnSync) {
88
+ const result = await backfillHubFromOutbox(db, hub.id, domain);
89
+ if (result.processed > 0 || result.errors > 0) {
90
+ console.log(`[hub-sync] Backfill ${hub.name}: ${result.processed} processed, ${result.errors} errors`);
91
+ }
92
+ }
93
+
94
+ // Update lastSyncAt
95
+ await db.update(federatedHubs).set({
96
+ lastSyncAt: new Date(),
97
+ }).where(eq(federatedHubs.id, hub.id));
98
+ } catch (err) {
99
+ console.error(`[hub-sync] Failed to sync hub ${hub.name}:`, err instanceof Error ? err.message : err);
100
+ }
101
+
102
+ // Stagger between hubs to avoid hammering remote instances
103
+ if (staleHubs.indexOf(hub) < staleHubs.length - 1) {
104
+ await new Promise((r) => setTimeout(r, STAGGER_DELAY_MS));
105
+ }
106
+ }
107
+ } catch (err) {
108
+ console.error('[hub-sync] Sync worker error:', err instanceof Error ? err.message : err);
109
+ }
110
+ }
111
+
112
+ nitro.hooks.hook('close', () => {
113
+ clearTimeout(startupTimer);
114
+ if (interval) {
115
+ clearInterval(interval);
116
+ interval = null;
117
+ }
118
+ });
119
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Notification email plugin.
3
+ * - Registers an instant email sender so createNotification() can fire emails.
4
+ * - Runs a digest scheduler that batches unread notifications for users who prefer daily/weekly digests.
5
+ */
6
+ import {
7
+ setNotificationEmailSender,
8
+ shouldEmailNotification,
9
+ getNotificationEmailTarget,
10
+ emailTemplates,
11
+ listNotifications,
12
+ } from '@commonpub/server';
13
+ import type { NotificationType } from '@commonpub/server';
14
+ import { users } from '@commonpub/schema';
15
+ import { and, isNotNull, eq } from 'drizzle-orm';
16
+
17
+ export default defineNitroPlugin((nitro) => {
18
+ if (process.env.NODE_ENV === 'test') return;
19
+
20
+ let digestInterval: ReturnType<typeof setInterval> | null = null;
21
+
22
+ const startupTimer = setTimeout(() => {
23
+ try {
24
+ const config = useConfig();
25
+ if (!config.features.emailNotifications) {
26
+ console.log('[notification-email] Email notifications disabled');
27
+ return;
28
+ }
29
+
30
+ const runtimeConfig = useRuntimeConfig();
31
+ const siteUrl = (runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`;
32
+ const siteName = config.instance.name || 'CommonPub';
33
+
34
+ // Register instant email sender.
35
+ // Uses useDB() instead of the passed db because createNotification() may be
36
+ // called inside a transaction — the fire-and-forget callback would run after
37
+ // the transaction commits, making the transaction-scoped db handle stale.
38
+ setNotificationEmailSender(async (_db, notification) => {
39
+ const freshDb = useDB();
40
+ const should = await shouldEmailNotification(
41
+ freshDb,
42
+ notification.userId,
43
+ notification.type as NotificationType,
44
+ );
45
+ if (!should) return;
46
+
47
+ const target = await getNotificationEmailTarget(freshDb, notification.userId);
48
+ if (!target) return;
49
+
50
+ const emailAdapter = useEmailAdapter();
51
+ const template = emailTemplates.notificationInstant(
52
+ siteName,
53
+ target.username,
54
+ {
55
+ title: notification.title,
56
+ message: notification.message,
57
+ url: notification.link ? `${siteUrl}${notification.link}` : siteUrl,
58
+ },
59
+ );
60
+ await emailAdapter.send({ ...template, to: target.email });
61
+ });
62
+
63
+ console.log('[notification-email] Instant email sender registered');
64
+
65
+ // Digest scheduler — runs every hour, sends digests for users whose digest window has elapsed
66
+ const DIGEST_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
67
+ digestInterval = setInterval(() => {
68
+ runDigest(siteUrl, siteName).catch((err) => {
69
+ console.error('[notification-email] Digest scheduler unexpected error:', err instanceof Error ? err.message : err);
70
+ });
71
+ }, DIGEST_INTERVAL_MS);
72
+
73
+ console.log('[notification-email] Digest scheduler started (interval: 1h)');
74
+ } catch (err) {
75
+ console.error('[notification-email] Failed to start:', err instanceof Error ? err.message : err);
76
+ }
77
+ }, 5_000);
78
+
79
+ async function runDigest(siteUrl: string, siteName: string): Promise<void> {
80
+ try {
81
+ const db = useDB();
82
+ const now = new Date();
83
+ const hour = now.getUTCHours();
84
+
85
+ // Daily digests go out at 8am UTC; weekly digests go out Monday at 8am UTC
86
+ const isDigestHour = hour === 8;
87
+ const isMonday = now.getUTCDay() === 1;
88
+
89
+ if (!isDigestHour) return;
90
+
91
+ // Find users with digest preferences
92
+ const digestUsers = await db
93
+ .select({
94
+ id: users.id,
95
+ email: users.email,
96
+ username: users.username,
97
+ emailNotifications: users.emailNotifications,
98
+ emailVerified: users.emailVerified,
99
+ })
100
+ .from(users)
101
+ .where(and(
102
+ isNotNull(users.emailNotifications),
103
+ eq(users.emailVerified, true),
104
+ ));
105
+
106
+ const emailAdapter = useEmailAdapter();
107
+ let sent = 0;
108
+
109
+ for (const user of digestUsers) {
110
+ const prefs = user.emailNotifications as { digest?: string } | null;
111
+ if (!prefs?.digest) continue;
112
+ if (prefs.digest === 'none') continue;
113
+ if (prefs.digest === 'weekly' && !isMonday) continue;
114
+
115
+ // Get unread notifications from the last digest period
116
+ const since = new Date(now);
117
+ if (prefs.digest === 'daily') {
118
+ since.setDate(since.getDate() - 1);
119
+ } else {
120
+ since.setDate(since.getDate() - 7);
121
+ }
122
+
123
+ const { items } = await listNotifications(db, {
124
+ userId: user.id,
125
+ read: false,
126
+ limit: 50,
127
+ });
128
+
129
+ // Filter to only notifications within the digest window
130
+ const recent = items.filter((n) => n.createdAt >= since);
131
+ if (recent.length === 0) continue;
132
+
133
+ const template = emailTemplates.notificationDigest(
134
+ siteName,
135
+ user.username,
136
+ recent.map((n) => ({
137
+ text: `${n.title}: ${n.message}`,
138
+ url: n.link ? `${siteUrl}${n.link}` : siteUrl,
139
+ })),
140
+ );
141
+
142
+ try {
143
+ await emailAdapter.send({ ...template, to: user.email });
144
+ sent++;
145
+ } catch (err) {
146
+ console.error(`[notification-email] Digest failed for ${user.username}:`, err instanceof Error ? err.message : err);
147
+ }
148
+ }
149
+
150
+ if (sent > 0) {
151
+ console.log(`[notification-email] Sent ${sent} digest email(s)`);
152
+ }
153
+ } catch (err) {
154
+ console.error('[notification-email] Digest scheduler error:', err instanceof Error ? err.message : err);
155
+ }
156
+ }
157
+
158
+ nitro.hooks.hook('close', () => {
159
+ clearTimeout(startupTimer);
160
+ if (digestInterval) {
161
+ clearInterval(digestInterval);
162
+ digestInterval = null;
163
+ }
164
+ });
165
+ });
@@ -0,0 +1,49 @@
1
+ import { ConsoleEmailAdapter, SmtpEmailAdapter, ResendEmailAdapter } from '@commonpub/server';
2
+ import type { EmailAdapter } from '@commonpub/server';
3
+
4
+ let cachedAdapter: EmailAdapter | null = null;
5
+
6
+ /**
7
+ * Create and cache an email adapter based on runtime config.
8
+ * Reusable by auth middleware, notification plugin, and any server route.
9
+ */
10
+ export function useEmailAdapter(): EmailAdapter {
11
+ if (cachedAdapter) return cachedAdapter;
12
+
13
+ const runtimeConfig = useRuntimeConfig();
14
+ const adapter = (runtimeConfig.emailAdapter as string) || 'console';
15
+
16
+ if (adapter === 'smtp') {
17
+ const host = runtimeConfig.smtpHost as string;
18
+ const port = parseInt(runtimeConfig.smtpPort as string, 10) || 587;
19
+ const user = runtimeConfig.smtpUser as string;
20
+ const pass = runtimeConfig.smtpPass as string;
21
+ const from = runtimeConfig.smtpFrom as string;
22
+
23
+ if (!host || !user || !pass || !from) {
24
+ console.warn('[email] SMTP configured but missing credentials — falling back to console');
25
+ cachedAdapter = new ConsoleEmailAdapter();
26
+ return cachedAdapter;
27
+ }
28
+
29
+ cachedAdapter = new SmtpEmailAdapter({ host, port, user, pass, from });
30
+ return cachedAdapter;
31
+ }
32
+
33
+ if (adapter === 'resend') {
34
+ const apiKey = runtimeConfig.resendApiKey as string;
35
+ const from = runtimeConfig.resendFrom as string;
36
+
37
+ if (!apiKey || !from) {
38
+ console.warn('[email] Resend configured but missing API key or from address — falling back to console');
39
+ cachedAdapter = new ConsoleEmailAdapter();
40
+ return cachedAdapter;
41
+ }
42
+
43
+ cachedAdapter = new ResendEmailAdapter({ apiKey, from });
44
+ return cachedAdapter;
45
+ }
46
+
47
+ cachedAdapter = new ConsoleEmailAdapter();
48
+ return cachedAdapter;
49
+ }
@@ -1,57 +0,0 @@
1
- import { getUnreadMessageCount } from '@commonpub/server';
2
-
3
- /** @deprecated Use /api/realtime/stream instead */
4
- export default defineEventHandler(async (event) => {
5
- console.warn('[deprecated] /api/messages/stream — use /api/realtime/stream instead');
6
- const user = requireAuth(event);
7
- const userId = user.id;
8
- const db = useDB();
9
-
10
- setResponseHeader(event, 'Content-Type', 'text/event-stream');
11
- setResponseHeader(event, 'Cache-Control', 'no-cache');
12
- setResponseHeader(event, 'Connection', 'keep-alive');
13
-
14
- const encoder = new TextEncoder();
15
- const stream = new ReadableStream({
16
- async start(controller) {
17
- let closed = false;
18
- function cleanup(): void {
19
- if (closed) return;
20
- closed = true;
21
- clearInterval(interval);
22
- clearInterval(keepalive);
23
- try { controller.close(); } catch { /* already closed */ }
24
- }
25
-
26
- const count = await getUnreadMessageCount(db, userId);
27
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'count', count })}\n\n`));
28
-
29
- const interval = setInterval(async () => {
30
- try {
31
- const currentCount = await getUnreadMessageCount(db, userId);
32
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'count', count: currentCount })}\n\n`));
33
- } catch {
34
- cleanup();
35
- }
36
- }, 10000);
37
-
38
- const keepalive = setInterval(() => {
39
- try {
40
- controller.enqueue(encoder.encode(': keepalive\n\n'));
41
- } catch {
42
- cleanup();
43
- }
44
- }, 30000);
45
-
46
- event.node.req.on('close', cleanup);
47
- },
48
- });
49
-
50
- return new Response(stream, {
51
- headers: {
52
- 'Content-Type': 'text/event-stream',
53
- 'Cache-Control': 'no-cache',
54
- 'Connection': 'keep-alive',
55
- },
56
- });
57
- });
@@ -1,61 +0,0 @@
1
- import { getUnreadCount } from '@commonpub/server';
2
-
3
- /** @deprecated Use /api/realtime/stream instead */
4
- export default defineEventHandler(async (event) => {
5
- console.warn('[deprecated] /api/notifications/stream — use /api/realtime/stream instead');
6
- const user = requireAuth(event);
7
- const userId = user.id;
8
- const db = useDB();
9
-
10
- setResponseHeader(event, 'Content-Type', 'text/event-stream');
11
- setResponseHeader(event, 'Cache-Control', 'no-cache');
12
- setResponseHeader(event, 'Connection', 'keep-alive');
13
-
14
- const encoder = new TextEncoder();
15
- const stream = new ReadableStream({
16
- async start(controller) {
17
- let closed = false;
18
- function cleanup(): void {
19
- if (closed) return;
20
- closed = true;
21
- clearInterval(interval);
22
- clearInterval(keepalive);
23
- try { controller.close(); } catch { /* already closed */ }
24
- }
25
-
26
- // Send initial count
27
- const count = await getUnreadCount(db, userId);
28
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'count', count })}\n\n`));
29
-
30
- // Poll every 10 seconds for new notifications
31
- const interval = setInterval(async () => {
32
- try {
33
- const currentCount = await getUnreadCount(db, userId);
34
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'count', count: currentCount })}\n\n`));
35
- } catch {
36
- cleanup();
37
- }
38
- }, 10000);
39
-
40
- // Send keepalive every 30 seconds
41
- const keepalive = setInterval(() => {
42
- try {
43
- controller.enqueue(encoder.encode(': keepalive\n\n'));
44
- } catch {
45
- cleanup();
46
- }
47
- }, 30000);
48
-
49
- // Clean up on close
50
- event.node.req.on('close', cleanup);
51
- },
52
- });
53
-
54
- return new Response(stream, {
55
- headers: {
56
- 'Content-Type': 'text/event-stream',
57
- 'Cache-Control': 'no-cache',
58
- 'Connection': 'keep-alive',
59
- },
60
- });
61
- });