@commonpub/layer 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/components/AppToast.vue +1 -1
  2. package/components/ContentAvatar.vue +98 -0
  3. package/components/CpubCriteriaBar.vue +88 -0
  4. package/components/CpubDateTimeField.vue +73 -0
  5. package/components/CpubMarkdown.vue +3 -1
  6. package/components/FormatToggle.vue +2 -2
  7. package/components/ImageUpload.vue +5 -8
  8. package/components/MirrorDetailModal.vue +3 -1
  9. package/components/MirrorRequestApproveModal.vue +3 -1
  10. package/components/ProductEditModal.vue +184 -0
  11. package/components/RemoteFollowDialog.vue +2 -2
  12. package/components/SearchSidebar.vue +14 -21
  13. package/components/ShareToHubModal.vue +3 -1
  14. package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
  15. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
  16. package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
  17. package/components/blocks/BlockCompareColumnsView.vue +92 -0
  18. package/components/blocks/BlockContentRenderer.vue +17 -0
  19. package/components/blocks/BlockCriteriaBarView.vue +25 -0
  20. package/components/blocks/BlockGalleryView.vue +5 -0
  21. package/components/blocks/BlockHtmlView.vue +26 -0
  22. package/components/blocks/BlockImageView.vue +4 -0
  23. package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
  24. package/components/blocks/BlockRoadmapView.vue +84 -0
  25. package/components/blocks/BlockSponsorsView.vue +89 -0
  26. package/components/blocks/BlockTableView.vue +49 -0
  27. package/components/blocks/BlockTabsView.vue +121 -0
  28. package/components/contest/ContestBodyCanvas.vue +155 -0
  29. package/components/contest/ContestCriteriaEditor.vue +79 -0
  30. package/components/contest/ContestEditor.vue +948 -0
  31. package/components/contest/ContestEntries.vue +1 -1
  32. package/components/contest/ContestEntryPrivateData.vue +126 -0
  33. package/components/contest/ContestHero.vue +114 -186
  34. package/components/contest/ContestJudgeManager.vue +6 -4
  35. package/components/contest/ContestJudgingCriteria.vue +5 -21
  36. package/components/contest/ContestPrizes.vue +8 -1
  37. package/components/contest/ContestProposalForm.vue +88 -0
  38. package/components/contest/ContestRules.vue +8 -1
  39. package/components/contest/ContestSidebar.vue +8 -2
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +3 -2
  43. package/components/contest/ContestSubmissionField.vue +141 -0
  44. package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
  45. package/components/contest/blocks/ContestTabPanel.vue +27 -0
  46. package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
  47. package/components/contest/blocks/HtmlBlock.vue +61 -0
  48. package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
  49. package/components/contest/blocks/RoadmapBlock.vue +127 -0
  50. package/components/contest/blocks/SponsorsBlock.vue +127 -0
  51. package/components/contest/blocks/TableBlock.vue +101 -0
  52. package/components/contest/blocks/TabsBlock.vue +168 -0
  53. package/components/editors/ArticleEditor.vue +9 -16
  54. package/components/editors/ExplainerEditor.vue +8 -5
  55. package/components/editors/ProjectEditor.vue +13 -10
  56. package/components/homepage/CustomHtmlSection.vue +11 -2
  57. package/components/hub/HubProducts.vue +4 -2
  58. package/components/nav/NavDropdown.vue +1 -5
  59. package/components/nav/NavLink.vue +2 -0
  60. package/components/views/ArticleView.vue +3 -56
  61. package/components/views/ExplainerView.vue +4 -0
  62. package/components/views/ProjectView.vue +83 -245
  63. package/composables/useContestEditor.ts +388 -0
  64. package/composables/useDocsPageTree.ts +154 -0
  65. package/composables/useDocsSiteSettings.ts +107 -0
  66. package/composables/useEditorAutosave.ts +131 -0
  67. package/composables/useEngagement.ts +13 -6
  68. package/composables/useFeatures.ts +9 -1
  69. package/composables/useFileUpload.ts +60 -0
  70. package/composables/useProfileContent.ts +84 -0
  71. package/composables/useSanitize.ts +38 -4
  72. package/composables/useScrollSpy.ts +87 -0
  73. package/layouts/admin.vue +41 -19
  74. package/layouts/default.vue +18 -9
  75. package/nuxt.config.ts +13 -0
  76. package/package.json +9 -9
  77. package/pages/[type]/index.vue +6 -1
  78. package/pages/admin/api-keys.vue +13 -3
  79. package/pages/admin/features.vue +2 -0
  80. package/pages/admin/federation.vue +1 -1
  81. package/pages/admin/layouts/[id].vue +30 -2
  82. package/pages/admin/settings.vue +2 -1
  83. package/pages/admin/users.vue +1 -1
  84. package/pages/admin/video-categories.vue +203 -0
  85. package/pages/cert/[code].vue +6 -2
  86. package/pages/contests/[slug]/edit.vue +4 -769
  87. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  88. package/pages/contests/[slug]/index.vue +93 -7
  89. package/pages/contests/[slug]/judge.vue +49 -26
  90. package/pages/contests/create.vue +5 -466
  91. package/pages/contests/index.vue +7 -2
  92. package/pages/cookies.vue +1 -1
  93. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  94. package/pages/docs/[siteSlug]/edit.vue +93 -231
  95. package/pages/events/[slug]/edit.vue +20 -20
  96. package/pages/events/create.vue +18 -18
  97. package/pages/events/index.vue +7 -2
  98. package/pages/hubs/[slug]/index.vue +34 -9
  99. package/pages/hubs/[slug]/invites.vue +312 -0
  100. package/pages/hubs/[slug]/members.vue +128 -0
  101. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  102. package/pages/hubs/index.vue +6 -1
  103. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  104. package/pages/learn/index.vue +8 -1
  105. package/pages/messages/index.vue +1 -1
  106. package/pages/mirror/[id].vue +1 -1
  107. package/pages/products/[slug].vue +55 -2
  108. package/pages/products/index.vue +6 -1
  109. package/pages/settings/account.vue +8 -8
  110. package/pages/settings/profile.vue +23 -14
  111. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  112. package/pages/u/[username]/followers.vue +11 -3
  113. package/pages/u/[username]/following.vue +10 -8
  114. package/pages/u/[username]/index.vue +73 -7
  115. package/pages/videos/index.vue +13 -10
  116. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  117. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  118. package/server/api/admin/api-keys/index.get.ts +1 -0
  119. package/server/api/admin/api-keys/index.post.ts +1 -0
  120. package/server/api/admin/federation/refederate.post.ts +18 -1
  121. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  122. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  123. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  124. package/server/api/admin/layouts/[id].delete.ts +1 -4
  125. package/server/api/admin/layouts/[id].get.ts +1 -4
  126. package/server/api/admin/layouts/[id].put.ts +1 -4
  127. package/server/api/auth/federated/login.post.ts +12 -5
  128. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  129. package/server/api/content/[id]/build.get.ts +11 -0
  130. package/server/api/content/[id]/report.post.ts +2 -0
  131. package/server/api/content/[id]/versions.get.ts +15 -0
  132. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  133. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  134. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  135. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  136. package/server/api/contests/[slug]/export.get.ts +43 -0
  137. package/server/api/contests/[slug]/judge.post.ts +8 -2
  138. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  139. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  140. package/server/api/contests/index.post.ts +1 -1
  141. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  142. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  143. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  144. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  145. package/server/api/events/[slug]/attendees.get.ts +10 -0
  146. package/server/api/events/[slug].get.ts +9 -0
  147. package/server/api/events/index.get.ts +8 -1
  148. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  149. package/server/api/federation/content/[id]/build.get.ts +10 -0
  150. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  151. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  152. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  153. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  154. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  155. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  156. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  157. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  158. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  159. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  160. package/server/api/products/[id].delete.ts +22 -2
  161. package/server/api/registry/ping.post.ts +17 -3
  162. package/server/api/search/index.get.ts +5 -3
  163. package/server/api/social/bookmark.get.ts +1 -0
  164. package/server/api/social/bookmark.post.ts +1 -0
  165. package/server/api/social/bookmarks.get.ts +1 -0
  166. package/server/api/social/comments/[id].delete.ts +1 -0
  167. package/server/api/social/comments.get.ts +1 -0
  168. package/server/api/social/comments.post.ts +1 -0
  169. package/server/api/social/like.get.ts +1 -0
  170. package/server/api/social/like.post.ts +1 -0
  171. package/server/api/users/[username]/content.get.ts +15 -3
  172. package/server/api/users/[username]/follow.delete.ts +1 -0
  173. package/server/api/users/[username]/follow.post.ts +1 -0
  174. package/server/api/users/[username]/followers.get.ts +2 -1
  175. package/server/api/users/[username]/following.get.ts +2 -1
  176. package/server/middleware/content-ap.ts +8 -3
  177. package/server/middleware/csrf.ts +93 -0
  178. package/server/plugins/federation-hub-sync.ts +48 -17
  179. package/server/plugins/notification-email.ts +22 -3
  180. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  181. package/server/routes/inbox.ts +14 -1
  182. package/server/routes/users/[username]/inbox.ts +13 -1
  183. package/server/utils/inbox.ts +7 -2
  184. package/server/utils/validate.ts +22 -0
  185. package/theme/base.css +5 -0
  186. package/theme/prose.css +20 -0
  187. package/theme/stoa-dark.css +4 -0
  188. package/types/contestBlocks.ts +122 -0
  189. package/utils/contestBlocks.ts +107 -0
  190. package/utils/contestBody.ts +25 -0
  191. package/utils/contestStages.ts +62 -0
  192. package/utils/contestSubmission.ts +97 -0
  193. package/utils/datetime.ts +45 -0
  194. package/utils/projectBlocks.ts +162 -0
  195. package/components/editors/BlogEditor.vue +0 -648
@@ -13,5 +13,14 @@ export default defineEventHandler(async (event) => {
13
13
  const result = await getEventBySlug(db, slug);
14
14
  if (!result) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
15
15
 
16
+ // Only published/active events are publicly viewable. Draft/cancelled/completed
17
+ // events are visible only to the creator or an admin (mirrors content/contest
18
+ // draft gating). 404 (not 403) so the slug's existence isn't leaked.
19
+ if (result.status !== 'published' && result.status !== 'active') {
20
+ const viewer = getOptionalUser(event);
21
+ const canView = !!viewer && (viewer.role === 'admin' || viewer.id === result.createdById);
22
+ if (!canView) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
23
+ }
24
+
16
25
  return result;
17
26
  });
@@ -23,9 +23,16 @@ export default defineEventHandler(async (event) => {
23
23
  userId = user.id;
24
24
  }
25
25
 
26
+ // hubId feeds a uuid SQL bind; reject a malformed value at the door (a
27
+ // non-uuid string reaching the bind throws an unhandled 500).
28
+ const hubId = (query.hubId as string) || undefined;
29
+ if (hubId && !isUuid(hubId)) {
30
+ throw createError({ statusCode: 400, statusMessage: 'Invalid hubId' });
31
+ }
32
+
26
33
  return listEvents(db, {
27
34
  status,
28
- hubId: (query.hubId as string) || undefined,
35
+ hubId,
29
36
  upcoming: query.upcoming === 'true',
30
37
  featured: query.featured === 'true',
31
38
  userId,
@@ -2,7 +2,7 @@ import { listFederatedHubPostReplies } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event) => {
4
4
  requireFeature('federation');
5
- const postId = getRouterParam(event, 'postId')!;
5
+ const { postId } = parseParams(event, { id: 'uuid', postId: 'uuid' });
6
6
  const query = getQuery(event);
7
7
  const db = useDB();
8
8
 
@@ -0,0 +1,10 @@
1
+ import { isFederatedBuildMarked } from '@commonpub/server';
2
+
3
+ // Hydration counterpart to the federated build toggle — see content/[id]/build.get.ts.
4
+ export default defineEventHandler(async (event): Promise<{ marked: boolean }> => {
5
+ requireFeature('federation');
6
+ const user = requireAuth(event);
7
+ const db = useDB();
8
+ const { id } = parseParams(event, { id: 'uuid' });
9
+ return { marked: await isFederatedBuildMarked(db, id, user.id) };
10
+ });
@@ -0,0 +1,17 @@
1
+ import { revokeInvite, getHubBySlug } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event): Promise<{ revoked: boolean }> => {
4
+ const user = requireAuth(event);
5
+ const db = useDB();
6
+ const { slug, id } = parseParams(event, { slug: 'string', id: 'uuid' });
7
+ const hub = await getHubBySlug(db, slug);
8
+ if (!hub) {
9
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
10
+ }
11
+
12
+ const revoked = await revokeInvite(db, id, user.id, hub.id);
13
+ if (!revoked) {
14
+ throw createError({ statusCode: 403, statusMessage: 'Not authorized to revoke invites' });
15
+ }
16
+ return { revoked };
17
+ });
@@ -1,4 +1,4 @@
1
- import { listInvites, getHubBySlug, getMember } from '@commonpub/server';
1
+ import { listInvites, getHubBySlug, getMember, hasPermission } from '@commonpub/server';
2
2
  import type { HubInviteItem } from '@commonpub/server';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<HubInviteItem[]> => {
@@ -10,9 +10,11 @@ export default defineEventHandler(async (event): Promise<HubInviteItem[]> => {
10
10
  throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
11
11
  }
12
12
 
13
- // Only moderators, admins, and owners can view invite lists
13
+ // Invite management is admin+ (matches createInvite/revokeInvite's manageMembers).
14
+ // Gating the list at the same level keeps moderators from seeing write controls
15
+ // they can't use.
14
16
  const member = await getMember(db, hub.id, user.id);
15
- if (!member || !['moderator', 'admin', 'owner'].includes(member.role)) {
17
+ if (!member || !hasPermission(member.role, 'manageMembers')) {
16
18
  throw createError({ statusCode: 403, statusMessage: 'Insufficient permissions' });
17
19
  }
18
20
 
@@ -7,8 +7,7 @@ import { getPollOptions, getUserPollVote } from '@commonpub/server';
7
7
  export default defineEventHandler(async (event) => {
8
8
  requireFeature('hubs');
9
9
  const db = useDB();
10
- const postId = getRouterParam(event, 'postId');
11
- if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
10
+ const { postId } = parseParams(event, { postId: 'uuid' });
12
11
 
13
12
  const options = await getPollOptions(db, postId);
14
13
  const user = getOptionalUser(event);
@@ -13,8 +13,7 @@ export default defineEventHandler(async (event) => {
13
13
  requireFeature('hubs');
14
14
  const user = requireAuth(event);
15
15
  const db = useDB();
16
- const postId = getRouterParam(event, 'postId');
17
- if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
16
+ const { postId } = parseParams(event, { postId: 'uuid' });
18
17
 
19
18
  const body = await parseBody(event, pollVoteSchema);
20
19
  const result = await voteOnPoll(db, postId, body.optionId, user.id);
@@ -13,8 +13,7 @@ export default defineEventHandler(async (event) => {
13
13
  requireFeature('hubs');
14
14
  const user = requireAuth(event);
15
15
  const db = useDB();
16
- const postId = getRouterParam(event, 'postId');
17
- if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
16
+ const { postId } = parseParams(event, { postId: 'uuid' });
18
17
 
19
18
  const body = await parseBody(event, voteSchema);
20
19
  return voteOnPost(db, postId, user.id, body.direction);
@@ -0,0 +1,15 @@
1
+ import { approveJoinRequest, getHubBySlug } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event): Promise<{ approved: boolean; error?: string }> => {
4
+ const user = requireAuth(event);
5
+ const db = useDB();
6
+ const { slug, userId } = parseParams(event, { slug: 'string', userId: 'uuid' });
7
+
8
+ const hub = await getHubBySlug(db, slug);
9
+ if (!hub) {
10
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
11
+ }
12
+
13
+ // Permission (manageMembers) is enforced inside approveJoinRequest.
14
+ return approveJoinRequest(db, user.id, hub.id, userId);
15
+ });
@@ -0,0 +1,15 @@
1
+ import { denyJoinRequest, getHubBySlug } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event): Promise<{ denied: boolean; error?: string }> => {
4
+ const user = requireAuth(event);
5
+ const db = useDB();
6
+ const { slug, userId } = parseParams(event, { slug: 'string', userId: 'uuid' });
7
+
8
+ const hub = await getHubBySlug(db, slug);
9
+ if (!hub) {
10
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
11
+ }
12
+
13
+ // Permission (manageMembers) is enforced inside denyJoinRequest.
14
+ return denyJoinRequest(db, user.id, hub.id, userId);
15
+ });
@@ -0,0 +1,20 @@
1
+ import { listJoinRequests, getHubBySlug, getMember } from '@commonpub/server';
2
+ import type { HubMemberItem } from '@commonpub/server';
3
+
4
+ export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[]; total: number }> => {
5
+ const user = requireAuth(event);
6
+ const db = useDB();
7
+ const { slug } = parseParams(event, { slug: 'string' });
8
+ const hub = await getHubBySlug(db, slug);
9
+ if (!hub) {
10
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
11
+ }
12
+
13
+ // Reviewing join requests is a member-management action (admin/owner).
14
+ const member = await getMember(db, hub.id, user.id);
15
+ if (!member || !['admin', 'owner'].includes(member.role)) {
16
+ throw createError({ statusCode: 403, statusMessage: 'Insufficient permissions' });
17
+ }
18
+
19
+ return listJoinRequests(db, hub.id);
20
+ });
@@ -3,8 +3,7 @@ import { deleteHubResource } from '@commonpub/server';
3
3
  export default defineEventHandler(async (event) => {
4
4
  const db = useDB();
5
5
  const user = requireAuth(event);
6
- const id = getRouterParam(event, 'id');
7
- if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing resource ID' });
6
+ const { id } = parseParams(event, { id: 'uuid' });
8
7
 
9
8
  const result = await deleteHubResource(db, id, user.id);
10
9
  if (!result.success) {
@@ -4,8 +4,7 @@ import { updateHubResourceSchema } from '@commonpub/schema';
4
4
  export default defineEventHandler(async (event) => {
5
5
  const db = useDB();
6
6
  const user = requireAuth(event);
7
- const id = getRouterParam(event, 'id');
8
- if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing resource ID' });
7
+ const { id } = parseParams(event, { id: 'uuid' });
9
8
 
10
9
  const input = await parseBody(event, updateHubResourceSchema);
11
10
 
@@ -1,11 +1,31 @@
1
1
  import { deleteProduct } from '@commonpub/server';
2
+ import { products } from '@commonpub/schema';
3
+ import { eq } from 'drizzle-orm';
2
4
 
3
5
  export default defineEventHandler(async (event): Promise<{ deleted: boolean }> => {
6
+ const user = requireAuth(event);
4
7
  const db = useDB();
5
- requirePermission(event, 'content.moderate');
6
8
  const { id } = parseParams(event, { id: 'uuid' });
7
9
 
8
- const deleted = await deleteProduct(db, id);
10
+ // Owner OR moderator may delete. Resolve the product's owner first.
11
+ const [product] = await db
12
+ .select({ createdById: products.createdById })
13
+ .from(products)
14
+ .where(eq(products.id, id))
15
+ .limit(1);
16
+
17
+ if (!product) {
18
+ throw createError({ statusCode: 404, statusMessage: 'Product not found' });
19
+ }
20
+
21
+ if (!ownerOrPermission(event, product.createdById, 'content.moderate')) {
22
+ throw createError({ statusCode: 403, statusMessage: 'Missing permission: content.moderate' });
23
+ }
24
+
25
+ // Pass userId for an owner-scoped data-layer delete; moderators (non-owners)
26
+ // are already gated above, so they delete unconditionally.
27
+ const isOwner = product.createdById === user.id;
28
+ const deleted = await deleteProduct(db, id, isOwner ? user.id : undefined);
9
29
 
10
30
  if (!deleted) {
11
31
  throw createError({ statusCode: 404, statusMessage: 'Product not found' });
@@ -1,4 +1,4 @@
1
- import { recordRegistryPing, createRateLimitStore, getClientIp } from '@commonpub/server';
1
+ import { recordRegistryPing, createRateLimitStore, getClientIp, recordActivitySeen } from '@commonpub/server';
2
2
  import { verifyInboxRequest, extractDomain } from '../../utils/inbox';
3
3
 
4
4
  /**
@@ -32,13 +32,27 @@ export default defineEventHandler(async (event) => {
32
32
  const preRl = await store.check(`registry:ping:ip:${getClientIp(event)}`, PRE_TIER);
33
33
  if (!preRl.allowed) reject429(preRl.resetAt);
34
34
 
35
- const { actorUri } = await verifyInboxRequest(event, 'registry-ping');
35
+ const { actorUri, body } = await verifyInboxRequest(event, 'registry-ping');
36
36
  const domain = extractDomain(actorUri);
37
37
 
38
38
  // Per-verified-domain cap (the signer is now cryptographically known).
39
39
  const rl = await store.check(`registry:ping:${domain}`, PING_TIER);
40
40
  if (!rl.allowed) reject429(rl.resetAt);
41
41
 
42
- const result = await recordRegistryPing(useDB(), domain, actorUri);
42
+ const db = useDB();
43
+
44
+ // Replay dedup: claim the verified activity id BEFORE recording the ping so a
45
+ // replayed, validly-signed ping can't re-trigger the NodeInfo pull. No id =
46
+ // process normally. Placed after verification so attacker-chosen ids can't be
47
+ // seeded.
48
+ const activityId = body.id;
49
+ if (typeof activityId === 'string' && activityId.length > 0) {
50
+ const first = await recordActivitySeen(db, activityId);
51
+ if (!first) {
52
+ return { status: 'ok' };
53
+ }
54
+ }
55
+
56
+ const result = await recordRegistryPing(db, domain, actorUri);
43
57
  return { status: result };
44
58
  });
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  const searchQuerySchema = z.object({
8
8
  q: z.string().max(200).optional(),
9
9
  type: z.string().optional(),
10
- sort: z.enum(['relevance', 'recent', 'popular']).optional(),
10
+ sort: z.enum(['relevance', 'recent', 'popular', 'likes']).optional(),
11
11
  difficulty: z.string().optional(),
12
12
  tags: z.string().optional(),
13
13
  author: z.string().optional(),
@@ -141,8 +141,10 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
141
141
  difficulty: params.difficulty as ContentFilters['difficulty'],
142
142
  tag: tagList[0],
143
143
  // Postgres has no relevance ranking (that's Meilisearch's job) — the old
144
- // path also fell back to recency for 'relevance'.
145
- sort: params.sort === 'popular' ? 'popular' : 'recent',
144
+ // path also fell back to recency for 'relevance'. 'popular'/'likes' pass
145
+ // through (listContent coerces them to recency when the merge federates,
146
+ // since federated rows carry no view/like counts — same as 'popular').
147
+ sort: params.sort === 'popular' || params.sort === 'likes' ? params.sort : 'recent',
146
148
  limit,
147
149
  offset,
148
150
  };
@@ -7,6 +7,7 @@ const checkSchema = z.object({
7
7
  });
8
8
 
9
9
  export default defineEventHandler(async (event): Promise<{ bookmarked: boolean }> => {
10
+ requireFeature('social');
10
11
  const user = requireAuth(event);
11
12
  const db = useDB();
12
13
  const query = parseQueryParams(event, checkSchema);
@@ -7,6 +7,7 @@ const toggleBookmarkSchema = z.object({
7
7
  });
8
8
 
9
9
  export default defineEventHandler(async (event): Promise<{ bookmarked: boolean }> => {
10
+ requireFeature('social');
10
11
  const user = requireAuth(event);
11
12
  const db = useDB();
12
13
  const input = await parseBody(event, toggleBookmarkSchema);
@@ -8,6 +8,7 @@ const bookmarksQuerySchema = z.object({
8
8
  });
9
9
 
10
10
  export default defineEventHandler(async (event): Promise<PaginatedResponse<BookmarkItem>> => {
11
+ requireFeature('social');
11
12
  const user = requireAuth(event);
12
13
  const db = useDB();
13
14
  const query = parseQueryParams(event, bookmarksQuerySchema);
@@ -1,6 +1,7 @@
1
1
  import { deleteComment } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
4
+ requireFeature('social');
4
5
  const user = requireAuth(event);
5
6
  const db = useDB();
6
7
  const { id } = parseParams(event, { id: 'uuid' });
@@ -11,6 +11,7 @@ const commentsQuerySchema = z.object({
11
11
  });
12
12
 
13
13
  export default defineEventHandler(async (event): Promise<CommentItem[]> => {
14
+ requireFeature('social');
14
15
  const db = useDB();
15
16
  const query = parseQueryParams(event, commentsQuerySchema);
16
17
 
@@ -6,6 +6,7 @@ import { createCommentSchema } from '@commonpub/schema';
6
6
  const FEDERABLE_COMMENT_TYPES = new Set(['project', 'article', 'blog', 'explainer']);
7
7
 
8
8
  export default defineEventHandler(async (event): Promise<CommentItem> => {
9
+ requireFeature('social');
9
10
  const user = requireAuth(event);
10
11
  const db = useDB();
11
12
  const config = useConfig();
@@ -8,6 +8,7 @@ const likeQuerySchema = z.object({
8
8
  });
9
9
 
10
10
  export default defineEventHandler(async (event): Promise<{ liked: boolean }> => {
11
+ requireFeature('social');
11
12
  const user = requireAuth(event);
12
13
  const db = useDB();
13
14
  const query = parseQueryParams(event, likeQuerySchema);
@@ -11,6 +11,7 @@ const toggleLikeSchema = z.object({
11
11
  const FEDERABLE_LIKE_TYPES = new Set(['project', 'article', 'blog', 'explainer']);
12
12
 
13
13
  export default defineEventHandler(async (event): Promise<{ liked: boolean }> => {
14
+ requireFeature('social');
14
15
  const user = requireAuth(event);
15
16
  const db = useDB();
16
17
  const config = useConfig();
@@ -1,21 +1,33 @@
1
1
  import { getUserByUsername, getUserContent } from '@commonpub/server';
2
- import type { PaginatedResponse, ContentListItem } from '@commonpub/server';
2
+ import type { ContentListItem } from '@commonpub/server';
3
3
  import { contentTypeSchema } from '@commonpub/schema';
4
4
  import { z } from 'zod';
5
5
 
6
6
  const userContentQuerySchema = z.object({
7
7
  type: contentTypeSchema.optional(),
8
+ cursor: z.string().optional(),
9
+ limit: z.coerce.number().int().positive().max(100).optional(),
10
+ // `?drafts=true` requests the owner's unpublished work; honoured server-side
11
+ // only when the authenticated viewer IS the profile owner (never trusted as-is).
12
+ drafts: z.enum(['true', 'false']).optional(),
8
13
  });
9
14
 
10
- export default defineEventHandler(async (event): Promise<PaginatedResponse<ContentListItem>> => {
15
+ export default defineEventHandler(async (event): Promise<{ items: ContentListItem[]; nextCursor: string | null }> => {
11
16
  const db = useDB();
12
17
  const { username } = parseParams(event, { username: 'string' });
13
18
  const query = parseQueryParams(event, userContentQuerySchema);
19
+ const viewer = getOptionalUser(event);
14
20
 
15
21
  const user = await getUserByUsername(db, username);
16
22
  if (!user) {
17
23
  throw createError({ statusCode: 404, statusMessage: 'User not found' });
18
24
  }
19
25
 
20
- return getUserContent(db, user.id, query.type);
26
+ return getUserContent(db, user.id, {
27
+ type: query.type,
28
+ cursor: query.cursor,
29
+ limit: query.limit,
30
+ drafts: query.drafts === 'true',
31
+ viewerId: viewer?.id,
32
+ });
21
33
  });
@@ -1,6 +1,7 @@
1
1
  import { getUserByUsername, unfollowUser } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event): Promise<{ unfollowed: boolean }> => {
4
+ requireFeature('social');
4
5
  const db = useDB();
5
6
  const user = requireAuth(event);
6
7
  const { username } = parseParams(event, { username: 'string' });
@@ -1,6 +1,7 @@
1
1
  import { getUserByUsername, followUser } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event): Promise<{ followed: boolean }> => {
4
+ requireFeature('social');
4
5
  const db = useDB();
5
6
  const user = requireAuth(event);
6
7
  const { username } = parseParams(event, { username: 'string' });
@@ -18,5 +18,6 @@ export default defineEventHandler(async (event): Promise<PaginatedResponse<Follo
18
18
  throw createError({ statusCode: 404, statusMessage: 'User not found' });
19
19
  }
20
20
 
21
- return listFollowers(db, target.id, query);
21
+ // Pass the viewer so each row carries isFollowing for the VIEWER (not the owner).
22
+ return listFollowers(db, target.id, query, getOptionalUser(event)?.id);
22
23
  });
@@ -18,5 +18,6 @@ export default defineEventHandler(async (event): Promise<PaginatedResponse<Follo
18
18
  throw createError({ statusCode: 404, statusMessage: 'User not found' });
19
19
  }
20
20
 
21
- return listFollowing(db, target.id, query);
21
+ // Pass the viewer so each row carries isFollowing for the VIEWER (not the owner).
22
+ return listFollowing(db, target.id, query, getOptionalUser(event)?.id);
22
23
  });
@@ -53,6 +53,10 @@ export default defineEventHandler(async (event) => {
53
53
  typeFilter,
54
54
  eq(contentItems.slug, slug),
55
55
  eq(contentItems.status, 'published'),
56
+ // Only PUBLIC content is dereferenceable over ActivityPub. Without this,
57
+ // an unauthenticated Accept: application/activity+json request would return
58
+ // the full body of a members-only/private item (audit session 204 — P0).
59
+ eq(contentItems.visibility, 'public'),
56
60
  isNull(contentItems.deletedAt),
57
61
  ))
58
62
  .limit(1);
@@ -68,9 +72,10 @@ export default defineEventHandler(async (event) => {
68
72
  title: row.content.title,
69
73
  slug: row.content.slug,
70
74
  description: row.content.description,
71
- content: typeof row.content.content === 'string'
72
- ? row.content.content
73
- : JSON.stringify(row.content.content),
75
+ // Pass blocks through as-is: contentToArticle renders BlockTuple[] to HTML.
76
+ // Pre-stringifying forced the string branch, shipping raw JSON as the AP
77
+ // `content` (remote instances showed JSON instead of rendered HTML). (session 204)
78
+ content: row.content.content,
74
79
  coverImageUrl: row.content.coverImageUrl,
75
80
  publishedAt: row.content.publishedAt,
76
81
  updatedAt: row.content.updatedAt,
@@ -0,0 +1,93 @@
1
+ // CSRF defense for cookie-authenticated custom `/api/*` routes (audit session 204).
2
+ //
3
+ // The custom Nitro `/api/*` routes authenticate the browser via the Better Auth
4
+ // SESSION COOKIE (resolved in middleware/auth.ts). Cookies are sent automatically
5
+ // on cross-site requests, so without an Origin check a malicious page could drive
6
+ // a logged-in user's browser to issue state-changing POST/PUT/PATCH/DELETE calls
7
+ // (classic CSRF).
8
+ //
9
+ // This middleware runs ahead of the route handler (Nitro runs middleware
10
+ // alphabetically — `csrf.ts` sorts before `features.ts`, `public-api-auth.ts`,
11
+ // `security.ts`, `theme.ts`; after `auth.ts`, `content-*`. It does NOT depend on
12
+ // auth's resolved session — it makes its own decision purely from the presence of
13
+ // the session cookie + the Origin/Referer header, so ordering relative to auth.ts
14
+ // is irrelevant) and rejects any unsafe-method `/api/*` request that:
15
+ // 1. carries a Better Auth session cookie (i.e. is cookie-authenticated), AND
16
+ // 2. whose Origin (or, lacking that, Referer) host does NOT match the request host.
17
+ //
18
+ // Requests with NO session cookie pass through untouched: bearer-token public-API
19
+ // callers (`/api/public/*`), AP inbox (`/`-level, HTTP-signature auth), and plain
20
+ // unauthenticated requests are not cookie-CSRF-able.
21
+ //
22
+ // Exemptions:
23
+ // - `/api/auth/*` — Better Auth enforces its own CSRF via trustedOrigins.
24
+ // - `/api/public/*` — bearer-token auth, no cookie reliance.
25
+ //
26
+ // Why legit usage is unaffected: a same-origin browser fetch/XHR always sends an
27
+ // `Origin` header whose host equals the page host (and the API host, same origin),
28
+ // so the host comparison passes. Cross-site attacker requests send the attacker's
29
+ // Origin (or, for top-level form posts, a Referer from the attacker's page), which
30
+ // won't match — and even Origin-less navigations carrying the cookie are blocked.
31
+ import { getBetterAuthSessionCookieName } from '../utils/betterAuthCookie';
32
+
33
+ const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
34
+
35
+ /** Extract the host (host:port) from an absolute URL string; null if unparseable. */
36
+ function hostOf(value: string | undefined): string | null {
37
+ if (!value) return null;
38
+ try {
39
+ return new URL(value).host;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export default defineEventHandler((event) => {
46
+ const method = event.method.toUpperCase();
47
+ if (!UNSAFE_METHODS.has(method)) return;
48
+
49
+ const url = getRequestURL(event);
50
+ const pathname = url.pathname;
51
+
52
+ // Only guard custom cookie-auth API routes.
53
+ if (!pathname.startsWith('/api/')) return;
54
+ // Better Auth owns its own CSRF; bearer-token public API doesn't use the cookie.
55
+ if (pathname.startsWith('/api/auth/') || pathname.startsWith('/api/public/')) return;
56
+
57
+ // Is this request cookie-authenticated? Check both possible cookie names
58
+ // (`__Secure-`-prefixed in prod / HTTPS, bare otherwise) so we don't depend on
59
+ // env detection being perfectly in sync — if EITHER is present, treat it as a
60
+ // cookie-auth attempt and enforce the origin check.
61
+ const hasSessionCookie =
62
+ getCookie(event, getBetterAuthSessionCookieName(true)) !== undefined ||
63
+ getCookie(event, getBetterAuthSessionCookieName(false)) !== undefined;
64
+
65
+ // No session cookie => not cookie-CSRF-able (bearer / public / anonymous). Pass.
66
+ if (!hasSessionCookie) return;
67
+
68
+ const requestHost = url.host;
69
+
70
+ // Prefer Origin (sent on all CORS-relevant requests incl. same-origin fetch);
71
+ // fall back to Referer for environments/requests that omit Origin.
72
+ const originHeader = getRequestHeader(event, 'origin');
73
+ const originHost = hostOf(originHeader);
74
+
75
+ if (originHost !== null) {
76
+ if (originHost !== requestHost) {
77
+ throw createError({ statusCode: 403, statusMessage: 'CSRF origin check failed' });
78
+ }
79
+ return;
80
+ }
81
+
82
+ const refererHost = hostOf(getRequestHeader(event, 'referer'));
83
+ if (refererHost !== null) {
84
+ if (refererHost !== requestHost) {
85
+ throw createError({ statusCode: 403, statusMessage: 'CSRF origin check failed' });
86
+ }
87
+ return;
88
+ }
89
+
90
+ // Cookie-authenticated unsafe request with NO Origin AND NO Referer: cannot be
91
+ // proven same-origin, so reject. Legitimate browser XHR/fetch always sends one.
92
+ throw createError({ statusCode: 403, statusMessage: 'CSRF origin check failed' });
93
+ });