@commonpub/layer 0.3.13 → 0.3.15

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.
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- defineProps<{
2
+ const { attachments } = defineProps<{
3
3
  attachments: Array<{ type: string; url: string; name?: string }>;
4
4
  }>();
5
5
 
@@ -10,6 +10,15 @@ function iconForType(type: string): string {
10
10
  return 'fa-solid fa-file';
11
11
  }
12
12
 
13
+ function isSafeUrl(url: string): boolean {
14
+ try {
15
+ const parsed = new URL(url);
16
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:';
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
13
22
  function fileName(att: { url: string; name?: string }): string {
14
23
  if (att.name) return att.name;
15
24
  try {
@@ -18,14 +27,18 @@ function fileName(att: { url: string; name?: string }): string {
18
27
  return 'attachment';
19
28
  }
20
29
  }
30
+
31
+ const safeAttachments = computed(() =>
32
+ attachments.filter((att) => att.url && att.type && isSafeUrl(att.url)),
33
+ );
21
34
  </script>
22
35
 
23
36
  <template>
24
- <div v-if="attachments.length > 0" class="cpub-attachments">
37
+ <div v-if="safeAttachments.length > 0" class="cpub-attachments">
25
38
  <div class="cpub-attachments-label">Attachments</div>
26
39
  <div class="cpub-attachments-list">
27
40
  <a
28
- v-for="(att, i) in attachments"
41
+ v-for="(att, i) in safeAttachments"
29
42
  :key="i"
30
43
  :href="att.url"
31
44
  target="_blank"
@@ -31,7 +31,7 @@ function handleSend(): void {
31
31
  v-for="msg in messages"
32
32
  :key="msg.id"
33
33
  class="cpub-msg"
34
- :class="{ own: msg.senderId === currentUserId }"
34
+ :class="{ 'cpub-msg-own': msg.senderId === currentUserId }"
35
35
  >
36
36
  <div v-if="msg.senderId !== currentUserId" class="cpub-msg-sender">
37
37
  <img v-if="msg.senderAvatarUrl" :src="msg.senderAvatarUrl" :alt="msg.senderName ?? ''" class="cpub-msg-avatar" />
@@ -81,7 +81,7 @@ function handleSend(): void {
81
81
  max-width: 70%;
82
82
  }
83
83
 
84
- .cpub-msg.own {
84
+ .cpub-msg.cpub-msg-own {
85
85
  align-self: flex-end;
86
86
  }
87
87
 
@@ -19,11 +19,16 @@ const iconMap: Record<string, string> = {
19
19
  follow: 'fa-solid fa-user-plus',
20
20
  mention: 'fa-solid fa-at',
21
21
  system: 'fa-solid fa-bell',
22
+ hub: 'fa-solid fa-users',
23
+ fork: 'fa-solid fa-code-fork',
24
+ build: 'fa-solid fa-hammer',
25
+ contest: 'fa-solid fa-trophy',
26
+ certificate: 'fa-solid fa-certificate',
22
27
  };
23
28
  </script>
24
29
 
25
30
  <template>
26
- <div class="cpub-notif" :class="{ unread: !notification.read }">
31
+ <div class="cpub-notif" :class="{ 'cpub-notif-unread': !notification.read }">
27
32
  <div class="cpub-notif-avatar-wrap">
28
33
  <img v-if="notification.actorAvatarUrl" :src="notification.actorAvatarUrl" :alt="notification.actorName ?? ''" class="cpub-notif-avatar" />
29
34
  <div v-else class="cpub-notif-avatar cpub-notif-avatar-fallback">
@@ -42,7 +47,7 @@ const iconMap: Record<string, string> = {
42
47
  {{ new Date(notification.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
43
48
  </time>
44
49
  </div>
45
- <NuxtLink v-if="notification.link || notification.targetUrl" :to="notification.link || notification.targetUrl || '#'" class="cpub-notif-link" aria-label="View">
50
+ <NuxtLink v-if="notification.link || notification.targetUrl" :to="notification.link || notification.targetUrl || '#'" class="cpub-notif-link" :aria-label="`View ${notification.type} notification`">
46
51
  <i class="fa-solid fa-arrow-right"></i>
47
52
  </NuxtLink>
48
53
  </div>
@@ -58,7 +63,7 @@ const iconMap: Record<string, string> = {
58
63
  border-bottom: var(--border-width-default) solid var(--border2);
59
64
  }
60
65
 
61
- .cpub-notif.unread {
66
+ .cpub-notif.cpub-notif-unread {
62
67
  background: var(--accent-bg);
63
68
  border-color: var(--accent-border);
64
69
  }
@@ -83,7 +83,9 @@ export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>
83
83
  },
84
84
  buildCount: 0,
85
85
  bookmarkCount: 0,
86
- attachments: Array.isArray(fc.attachments) ? (fc.attachments as Array<{ type: string; url: string; name?: string }>) : [],
86
+ attachments: Array.isArray(fc.attachments)
87
+ ? (fc.attachments as Array<Record<string, unknown>>).filter((a) => typeof a.type === 'string' && typeof a.url === 'string').map((a) => ({ type: a.type as string, url: a.url as string, name: typeof a.name === 'string' ? a.name : undefined }))
88
+ : [],
87
89
  } satisfies ContentViewData;
88
90
  });
89
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -46,12 +46,12 @@
46
46
  "zod": "^4.3.6",
47
47
  "@commonpub/auth": "0.5.0",
48
48
  "@commonpub/config": "0.7.0",
49
- "@commonpub/docs": "0.5.2",
50
49
  "@commonpub/editor": "0.5.0",
51
- "@commonpub/learning": "0.5.0",
52
50
  "@commonpub/protocol": "0.9.4",
53
- "@commonpub/schema": "0.8.11",
54
- "@commonpub/server": "2.11.0",
51
+ "@commonpub/docs": "0.5.2",
52
+ "@commonpub/schema": "0.8.12",
53
+ "@commonpub/learning": "0.5.0",
54
+ "@commonpub/server": "2.11.2",
55
55
  "@commonpub/ui": "0.7.1"
56
56
  },
57
57
  "scripts": {}
@@ -92,12 +92,10 @@ useSeoMeta({
92
92
  description: () => post.value?.content?.slice(0, 160) ?? '',
93
93
  });
94
94
 
95
- if (hub.value?.url) {
96
- useHead({
97
- link: [{ rel: 'canonical', href: hub.value.url }],
98
- meta: [{ name: 'robots', content: 'noindex, follow' }],
99
- });
100
- }
95
+ useHead({
96
+ link: computed(() => hub.value?.url ? [{ rel: 'canonical', href: hub.value.url }] : []),
97
+ meta: computed(() => hub.value?.url ? [{ name: 'robots', content: 'noindex, follow' }] : []),
98
+ });
101
99
  </script>
102
100
 
103
101
  <template>
@@ -145,7 +143,7 @@ if (hub.value?.url) {
145
143
  </div>
146
144
 
147
145
  <div class="cpub-post-actions">
148
- <button class="cpub-post-action-btn" :class="{ active: liked }" :disabled="liking" @click="handleLike" :title="isAuthenticated ? (liked ? 'Unlike' : 'Like') : 'Log in to like'">
146
+ <button class="cpub-post-action-btn" :class="{ active: liked }" :disabled="liking" @click="handleLike" :aria-label="liked ? 'Unlike post' : 'Like post'" :aria-pressed="liked">
149
147
  <i :class="liked ? 'fa-solid fa-heart' : 'fa-regular fa-heart'"></i>
150
148
  {{ likeCount }}
151
149
  </button>
@@ -164,6 +162,7 @@ if (hub.value?.url) {
164
162
  class="cpub-reply-input"
165
163
  type="text"
166
164
  placeholder="Write a reply (sent via federation)..."
165
+ aria-label="Write a reply"
167
166
  @keydown.enter="handleReply"
168
167
  />
169
168
  <button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="replying || !replyContent.trim()" @click="handleReply">
@@ -251,7 +250,7 @@ if (hub.value?.url) {
251
250
  width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;
252
251
  background: var(--surface2); border: 1px solid var(--border);
253
252
  font-family: var(--font-mono); font-size: 10px; font-weight: 700; color: var(--text-dim);
254
- border-radius: 50%; overflow: hidden;
253
+ overflow: hidden;
255
254
  }
256
255
  .cpub-post-avatar-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
257
256
 
@@ -8,6 +8,7 @@ const handle = computed(() => decodeURIComponent(route.params.handle as string))
8
8
 
9
9
  const { searchResult, searchLoading, searchError, searchRemoteUser, followRemoteUser, unfollowRemoteUser } = useFederation();
10
10
  const { user } = useAuth();
11
+ const toast = useToast();
11
12
 
12
13
  const content = ref<FederatedContentItem[]>([]);
13
14
  const contentLoading = ref(false);
@@ -29,7 +30,7 @@ async function sendDm(): Promise<void> {
29
30
  showDmForm.value = false;
30
31
  setTimeout(() => { dmSent.value = false; }, 5000);
31
32
  } catch {
32
- // TODO: show error toast
33
+ toast.error('Failed to send message');
33
34
  } finally {
34
35
  dmSending.value = false;
35
36
  }
@@ -107,6 +108,8 @@ function stripHtml(html: string): string {
107
108
  'cpub-remote-profile__follow-btn--pending': searchResult.isFollowPending,
108
109
  }"
109
110
  :disabled="searchResult.isFollowPending"
111
+ :aria-pressed="searchResult.isFollowing"
112
+ :aria-label="searchResult.isFollowing ? 'Unfollow user' : searchResult.isFollowPending ? 'Follow request pending' : 'Follow user'"
110
113
  @click="searchResult.isFollowing ? onUnfollow() : onFollow()"
111
114
  >
112
115
  {{ searchResult.isFollowing ? 'Following' : searchResult.isFollowPending ? 'Pending' : 'Follow' }}
@@ -128,6 +131,7 @@ function stripHtml(html: string): string {
128
131
  class="cpub-remote-profile__dm-textarea"
129
132
  placeholder="Write a message..."
130
133
  rows="3"
134
+ aria-label="Direct message"
131
135
  ></textarea>
132
136
  <div class="cpub-remote-profile__dm-actions">
133
137
  <button class="cpub-remote-profile__dm-send" :disabled="dmSending || !dmBody.trim()" @click="sendDm">
@@ -182,7 +186,7 @@ function stripHtml(html: string): string {
182
186
  color: var(--text-2);
183
187
  padding: var(--space-8) 0;
184
188
  }
185
- .cpub-remote-profile__error { color: var(--error, #f85149); }
189
+ .cpub-remote-profile__error { color: var(--error); }
186
190
  .cpub-remote-profile__header {
187
191
  display: flex;
188
192
  align-items: center;
@@ -317,7 +321,7 @@ function stripHtml(html: string): string {
317
321
  }
318
322
  .cpub-remote-profile__dm-sent {
319
323
  font-size: var(--font-size-sm);
320
- color: var(--green, #22c55e);
324
+ color: var(--green);
321
325
  font-weight: 600;
322
326
  margin-bottom: var(--space-4);
323
327
  }
@@ -62,20 +62,22 @@ async function toggleLike(): Promise<void> {
62
62
 
63
63
  // Mod actions
64
64
  async function togglePin(): Promise<void> {
65
+ const wasPinned = post.value?.isPinned;
65
66
  try {
66
67
  await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}/pin`, { method: 'POST' });
67
68
  await refreshPost();
68
- toast.success(post.value?.isPinned ? 'Post unpinned' : 'Post pinned');
69
+ toast.success(wasPinned ? 'Post unpinned' : 'Post pinned');
69
70
  } catch {
70
71
  toast.error('Failed to toggle pin');
71
72
  }
72
73
  }
73
74
 
74
75
  async function toggleLock(): Promise<void> {
76
+ const wasLocked = post.value?.isLocked;
75
77
  try {
76
78
  await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}/lock`, { method: 'POST' });
77
79
  await refreshPost();
78
- toast.success(post.value?.isLocked ? 'Post unlocked' : 'Post locked');
80
+ toast.success(wasLocked ? 'Post unlocked' : 'Post locked');
79
81
  } catch {
80
82
  toast.error('Failed to toggle lock');
81
83
  }
@@ -150,7 +152,7 @@ useSeoMeta({
150
152
  </div>
151
153
 
152
154
  <div v-if="editing" class="cpub-post-edit-form">
153
- <textarea v-model="editContent" class="cpub-post-edit-textarea" rows="4"></textarea>
155
+ <textarea v-model="editContent" class="cpub-post-edit-textarea" rows="4" aria-label="Edit post content"></textarea>
154
156
  <div class="cpub-post-edit-actions">
155
157
  <button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving || !editContent.trim()" @click="saveEdit">
156
158
  {{ saving ? 'Saving...' : 'Save' }}
@@ -177,7 +179,7 @@ useSeoMeta({
177
179
  </div>
178
180
 
179
181
  <div class="cpub-post-actions">
180
- <button class="cpub-post-action-btn" :class="{ active: post.isLiked }" :disabled="liking || !isAuthenticated" @click="toggleLike" :title="isAuthenticated ? 'Like' : 'Log in to like'">
182
+ <button class="cpub-post-action-btn" :class="{ active: post.isLiked }" :disabled="liking || !isAuthenticated" @click="toggleLike" :aria-label="post.isLiked ? 'Unlike post' : 'Like post'" :aria-pressed="post.isLiked ?? false">
181
183
  <i :class="post.isLiked ? 'fa-solid fa-heart' : 'fa-regular fa-heart'"></i>
182
184
  {{ post.likeCount ?? 0 }}
183
185
  </button>
@@ -210,7 +212,7 @@ useSeoMeta({
210
212
  Replying to a comment <button class="cpub-cancel-reply" @click="replyingTo = null"><i class="fa-solid fa-xmark"></i></button>
211
213
  </div>
212
214
  <div class="cpub-reply-row">
213
- <input v-model="replyContent" class="cpub-reply-input" type="text" placeholder="Write a reply..." @keydown.enter="handleReply" />
215
+ <input v-model="replyContent" class="cpub-reply-input" type="text" placeholder="Write a reply..." aria-label="Write a reply" @keydown.enter="handleReply" />
214
216
  <button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="replying || !replyContent.trim()" @click="handleReply">
215
217
  <i class="fa-solid fa-paper-plane"></i> Reply
216
218
  </button>
@@ -352,7 +354,7 @@ useSeoMeta({
352
354
  display: flex; gap: 6px;
353
355
  }
354
356
 
355
- .cpub-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
357
+ .cpub-btn-primary { background: var(--accent); color: var(--accent-text, #fff); border-color: var(--accent); }
356
358
  .cpub-btn-primary:hover:not(:disabled) { opacity: 0.9; }
357
359
  .cpub-btn-primary:disabled { opacity: 0.5; cursor: default; }
358
360
  .cpub-btn-danger { color: var(--red); border-color: var(--red); }
@@ -2,7 +2,6 @@
2
2
  const route = useRoute();
3
3
  const conversationId = route.params.conversationId as string;
4
4
 
5
- useSeoMeta({ title: () => `Message — ${participantLabel.value}` });
6
5
  definePageMeta({ middleware: 'auth' });
7
6
 
8
7
  const { user } = useAuth();
@@ -57,17 +56,18 @@ const participantLabel = computed(() => {
57
56
  return others.length > 0 ? others.join(', ') : 'Conversation';
58
57
  });
59
58
 
59
+ useSeoMeta({ title: () => `Message — ${participantLabel.value}` });
60
+
60
61
  async function handleSend(text: string): Promise<void> {
61
62
  await $fetch(`/api/messages/${conversationId}` as string, {
62
63
  method: 'POST',
63
64
  body: { body: text },
64
65
  });
65
66
  // SSE will pick up the new message, but also do an immediate refresh for responsiveness
66
- refresh().then((result: any) => {
67
- if (result?.data?.value) {
68
- messages.value = result.data.value;
69
- }
70
- });
67
+ await refresh();
68
+ if (initialMessages.value) {
69
+ messages.value = [...initialMessages.value];
70
+ }
71
71
  }
72
72
  </script>
73
73
 
@@ -117,6 +117,7 @@ useSeoMeta({
117
117
  <p v-if="transformedContent.author.bio" class="cpub-mirror-bio">{{ stripHtml(transformedContent.author.bio) }}</p>
118
118
  </div>
119
119
  </div>
120
+ <!-- Content is sanitized on ingest (inboxHandlers.ts → sanitizeHtml). Safe for v-html. -->
120
121
  <div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
121
122
  <ContentAttachments v-if="transformedContent.attachments?.length" :attachments="transformedContent.attachments" />
122
123
  <div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
@@ -146,7 +147,7 @@ useSeoMeta({
146
147
  display: flex; align-items: center; gap: 4px; font-size: 11px;
147
148
  }
148
149
  .cpub-fed-banner-follow {
149
- margin-left: auto; background: var(--accent); color: #fff; border: none;
150
+ margin-left: auto; background: var(--accent); color: var(--accent-text, #fff); border: none;
150
151
  font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer;
151
152
  display: flex; align-items: center; gap: 4px; white-space: nowrap;
152
153
  }
@@ -2,7 +2,7 @@ import { federateDirectMessage, resolveRemoteHandle } from '@commonpub/server';
2
2
  import { z } from 'zod';
3
3
 
4
4
  const dmSchema = z.object({
5
- handle: z.string().min(3),
5
+ handle: z.string().min(3).regex(/^@?[\w.-]+@[\w.-]+\.\w+$/, 'Invalid federation handle'),
6
6
  body: z.string().min(1).max(10000),
7
7
  });
8
8
 
@@ -18,7 +18,12 @@ export default defineEventHandler(async (event) => {
18
18
  throw createError({ statusCode: 404, statusMessage: 'Could not resolve remote user' });
19
19
  }
20
20
 
21
- await federateDirectMessage(db, user.id, resolved.actorUri, input.body, config.instance.domain);
21
+ try {
22
+ await federateDirectMessage(db, user.id, resolved.actorUri, input.body, config.instance.domain);
23
+ } catch (err) {
24
+ console.error('[federation-dm] Failed to deliver:', err);
25
+ throw createError({ statusCode: 502, statusMessage: 'Failed to deliver message to remote server' });
26
+ }
22
27
 
23
28
  return {
24
29
  sent: true,
@@ -1,4 +1,4 @@
1
- import { likePost, unlikePost, hasLikedPost, getHubBySlug, getPostById, federateHubPostLike } from '@commonpub/server';
1
+ import { likePost, unlikePost, hasLikedPost, getHubBySlug, getPostById, federateHubPostLike, checkBan } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event) => {
4
4
  const user = requireAuth(event);
@@ -12,6 +12,10 @@ export default defineEventHandler(async (event) => {
12
12
  const post = await getPostById(db, postId);
13
13
  if (!post || post.hubId !== community.id) throw createError({ statusCode: 404, statusMessage: 'Post not found' });
14
14
 
15
+ // Banned users cannot like posts
16
+ const ban = await checkBan(db, community.id, user.id);
17
+ if (ban) throw createError({ statusCode: 403, statusMessage: 'You are banned from this hub' });
18
+
15
19
  const alreadyLiked = await hasLikedPost(db, user.id, postId);
16
20
  if (alreadyLiked) {
17
21
  await unlikePost(db, user.id, postId);
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event): Promise<HubPostItem> => {
14
14
  }
15
15
 
16
16
  const input = await parseBody(event, editPostSchema);
17
- const updated = await editPost(db, postId, user.id, input);
17
+ const updated = await editPost(db, postId, user.id, community.id, input);
18
18
  if (!updated) {
19
19
  throw createError({ statusCode: 403, statusMessage: 'Not authorized to edit this post' });
20
20
  }
@@ -57,11 +57,16 @@ export default defineEventHandler(async (event): Promise<HubPostItem> => {
57
57
  originDomain: fedContent.originDomain,
58
58
  });
59
59
 
60
- const post = await createPost(db, user.id, {
61
- hubId: hub.id,
62
- type: 'share',
63
- content: sharePayload,
64
- });
60
+ let post;
61
+ try {
62
+ post = await createPost(db, user.id, {
63
+ hubId: hub.id,
64
+ type: 'share',
65
+ content: sharePayload,
66
+ });
67
+ } catch {
68
+ throw createError({ statusCode: 403, statusMessage: 'You must be a hub member to share content' });
69
+ }
65
70
 
66
71
  // Federate the share using the object URI of the federated content
67
72
  if (config.features.federation && config.features.federateHubs) {