@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 +95 -0
- package/components/CommentSection.vue +1 -1
- package/components/MessageThread.vue +15 -2
- package/composables/useFeatures.ts +2 -0
- package/nuxt.config.ts +1 -0
- package/package.json +6 -6
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/messages/index.vue +33 -3
- package/pages/settings/profile.vue +153 -1
- package/server/api/files/upload-from-url.post.ts +16 -9
- package/server/api/image-proxy.get.ts +15 -8
- package/server/api/realtime/stream.get.ts +6 -2
- package/server/middleware/auth.ts +2 -37
- package/server/plugins/federation-hub-sync.ts +119 -0
- package/server/plugins/notification-email.ts +165 -0
- package/server/utils/email.ts +49 -0
- package/server/api/messages/stream.get.ts +0 -57
- package/server/api/notifications/stream.get.ts +0 -61
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"
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
55
|
-
"@commonpub/
|
|
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">·</span>
|
|
236
236
|
<time class="cpub-post-time">{{ formatDate(reply.createdAt) }}</time>
|
|
237
237
|
</div>
|
|
238
|
-
<div class="cpub-reply-content"
|
|
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">·</span>
|
|
255
255
|
<time class="cpub-post-time">{{ formatDate(child.createdAt) }}</time>
|
|
256
256
|
</div>
|
|
257
|
-
<div class="cpub-reply-content"
|
|
257
|
+
<div class="cpub-reply-content"><MentionText :text="child.content" /></div>
|
|
258
258
|
</div>
|
|
259
259
|
</div>
|
|
260
260
|
</div>
|
package/pages/messages/index.vue
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
});
|