@commonpub/layer 0.81.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 (212) 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 +11 -3
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +54 -20
  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/useAuth.ts +13 -0
  64. package/composables/useCan.ts +23 -0
  65. package/composables/useContestEditor.ts +388 -0
  66. package/composables/useDocsPageTree.ts +154 -0
  67. package/composables/useDocsSiteSettings.ts +107 -0
  68. package/composables/useEditorAutosave.ts +131 -0
  69. package/composables/useEngagement.ts +13 -6
  70. package/composables/useFeatures.ts +9 -1
  71. package/composables/useFileUpload.ts +60 -0
  72. package/composables/useProfileContent.ts +84 -0
  73. package/composables/useSanitize.ts +38 -4
  74. package/composables/useScrollSpy.ts +87 -0
  75. package/layouts/admin.vue +43 -18
  76. package/layouts/default.vue +18 -9
  77. package/nuxt.config.ts +13 -0
  78. package/package.json +8 -8
  79. package/pages/[type]/index.vue +6 -1
  80. package/pages/admin/api-keys.vue +13 -3
  81. package/pages/admin/features.vue +2 -0
  82. package/pages/admin/federation.vue +1 -1
  83. package/pages/admin/layouts/[id].vue +30 -2
  84. package/pages/admin/roles.vue +286 -0
  85. package/pages/admin/settings.vue +2 -1
  86. package/pages/admin/users.vue +81 -1
  87. package/pages/admin/video-categories.vue +203 -0
  88. package/pages/cert/[code].vue +6 -2
  89. package/pages/contests/[slug]/edit.vue +4 -764
  90. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  91. package/pages/contests/[slug]/index.vue +97 -8
  92. package/pages/contests/[slug]/judge.vue +49 -26
  93. package/pages/contests/create.vue +5 -466
  94. package/pages/contests/index.vue +7 -2
  95. package/pages/cookies.vue +1 -1
  96. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  97. package/pages/docs/[siteSlug]/edit.vue +93 -231
  98. package/pages/events/[slug]/edit.vue +20 -20
  99. package/pages/events/create.vue +18 -18
  100. package/pages/events/index.vue +7 -2
  101. package/pages/hubs/[slug]/index.vue +34 -9
  102. package/pages/hubs/[slug]/invites.vue +312 -0
  103. package/pages/hubs/[slug]/members.vue +128 -0
  104. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  105. package/pages/hubs/index.vue +6 -1
  106. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  107. package/pages/learn/index.vue +8 -1
  108. package/pages/messages/index.vue +1 -1
  109. package/pages/mirror/[id].vue +1 -1
  110. package/pages/products/[slug].vue +55 -2
  111. package/pages/products/index.vue +6 -1
  112. package/pages/settings/account.vue +8 -8
  113. package/pages/settings/profile.vue +23 -14
  114. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  115. package/pages/u/[username]/followers.vue +11 -3
  116. package/pages/u/[username]/following.vue +10 -8
  117. package/pages/u/[username]/index.vue +73 -7
  118. package/pages/videos/index.vue +13 -10
  119. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  120. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  121. package/server/api/admin/api-keys/index.get.ts +1 -0
  122. package/server/api/admin/api-keys/index.post.ts +1 -0
  123. package/server/api/admin/federation/refederate.post.ts +18 -1
  124. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  125. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  126. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  127. package/server/api/admin/layouts/[id].delete.ts +1 -4
  128. package/server/api/admin/layouts/[id].get.ts +1 -4
  129. package/server/api/admin/layouts/[id].put.ts +1 -4
  130. package/server/api/admin/permissions.get.ts +14 -0
  131. package/server/api/admin/roles/[id]/index.delete.ts +25 -0
  132. package/server/api/admin/roles/[id]/index.put.ts +24 -0
  133. package/server/api/admin/roles/index.get.ts +10 -0
  134. package/server/api/admin/roles/index.post.ts +27 -0
  135. package/server/api/admin/users/[id]/role.put.ts +20 -1
  136. package/server/api/admin/users/[id]/roles.get.ts +10 -0
  137. package/server/api/admin/users/[id]/roles.put.ts +17 -0
  138. package/server/api/auth/federated/login.post.ts +12 -5
  139. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  140. package/server/api/content/[id]/build.get.ts +11 -0
  141. package/server/api/content/[id]/report.post.ts +2 -0
  142. package/server/api/content/[id]/versions.get.ts +15 -0
  143. package/server/api/contests/[slug]/advance.post.ts +10 -5
  144. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  145. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  146. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  147. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  148. package/server/api/contests/[slug]/export.get.ts +43 -0
  149. package/server/api/contests/[slug]/index.get.ts +10 -2
  150. package/server/api/contests/[slug]/index.put.ts +11 -2
  151. package/server/api/contests/[slug]/judge.post.ts +8 -2
  152. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  153. package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
  154. package/server/api/contests/[slug]/transition.post.ts +8 -3
  155. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  156. package/server/api/contests/index.post.ts +1 -1
  157. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  158. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  159. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  160. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  161. package/server/api/events/[slug]/attendees.get.ts +10 -0
  162. package/server/api/events/[slug].get.ts +9 -0
  163. package/server/api/events/index.get.ts +8 -1
  164. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  165. package/server/api/federation/content/[id]/build.get.ts +10 -0
  166. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  167. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  168. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  169. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  170. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  171. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  172. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  173. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  174. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  175. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  176. package/server/api/me.get.ts +7 -0
  177. package/server/api/products/[id].delete.ts +22 -2
  178. package/server/api/registry/ping.post.ts +17 -3
  179. package/server/api/search/index.get.ts +5 -3
  180. package/server/api/social/bookmark.get.ts +1 -0
  181. package/server/api/social/bookmark.post.ts +1 -0
  182. package/server/api/social/bookmarks.get.ts +1 -0
  183. package/server/api/social/comments/[id].delete.ts +1 -0
  184. package/server/api/social/comments.get.ts +1 -0
  185. package/server/api/social/comments.post.ts +1 -0
  186. package/server/api/social/like.get.ts +1 -0
  187. package/server/api/social/like.post.ts +1 -0
  188. package/server/api/users/[username]/content.get.ts +15 -3
  189. package/server/api/users/[username]/follow.delete.ts +1 -0
  190. package/server/api/users/[username]/follow.post.ts +1 -0
  191. package/server/api/users/[username]/followers.get.ts +2 -1
  192. package/server/api/users/[username]/following.get.ts +2 -1
  193. package/server/middleware/content-ap.ts +8 -3
  194. package/server/middleware/csrf.ts +93 -0
  195. package/server/plugins/federation-hub-sync.ts +48 -17
  196. package/server/plugins/notification-email.ts +22 -3
  197. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  198. package/server/routes/inbox.ts +14 -1
  199. package/server/routes/users/[username]/inbox.ts +13 -1
  200. package/server/utils/inbox.ts +7 -2
  201. package/server/utils/validate.ts +22 -0
  202. package/theme/base.css +5 -0
  203. package/theme/prose.css +20 -0
  204. package/theme/stoa-dark.css +4 -0
  205. package/types/contestBlocks.ts +122 -0
  206. package/utils/contestBlocks.ts +107 -0
  207. package/utils/contestBody.ts +25 -0
  208. package/utils/contestStages.ts +62 -0
  209. package/utils/contestSubmission.ts +97 -0
  210. package/utils/datetime.ts +45 -0
  211. package/utils/projectBlocks.ts +162 -0
  212. package/components/editors/BlogEditor.vue +0 -648
@@ -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
+ });
@@ -9,7 +9,7 @@ import {
9
9
  fetchRemoteHubFollowers,
10
10
  } from '@commonpub/server';
11
11
  import { federatedHubs } from '@commonpub/schema';
12
- import { eq, and, or, lt, isNull } from 'drizzle-orm';
12
+ import { eq, and, or, lt, isNull, inArray } from 'drizzle-orm';
13
13
 
14
14
  const MAX_HUBS_PER_CYCLE = 5;
15
15
  const STAGGER_DELAY_MS = 2_000;
@@ -56,28 +56,57 @@ export default defineNitroPlugin((nitro) => {
56
56
  const now = new Date();
57
57
  const staleThreshold = new Date(now.getTime() - intervalMs);
58
58
 
59
- // Find accepted, non-hidden hubs where lastSyncAt is older than the interval or null
60
- const staleHubs = await db
61
- .select({
62
- id: federatedHubs.id,
63
- actorUri: federatedHubs.actorUri,
64
- name: federatedHubs.name,
65
- lastSyncAt: federatedHubs.lastSyncAt,
66
- })
59
+ // Stale predicate: lastSyncAt is null or older than the interval. Reused for
60
+ // both the candidate select and the atomic claim so the claim only succeeds
61
+ // while the row is still stale (compare-and-claim).
62
+ const isStale = or(
63
+ isNull(federatedHubs.lastSyncAt),
64
+ lt(federatedHubs.lastSyncAt, staleThreshold),
65
+ );
66
+
67
+ // Find accepted, non-hidden hubs that are stale (candidates). We capture the
68
+ // prior lastSyncAt here (before the claim overwrites it) so first-sync
69
+ // detection survives the atomic claim below.
70
+ const candidates = await db
71
+ .select({ id: federatedHubs.id, lastSyncAt: federatedHubs.lastSyncAt })
67
72
  .from(federatedHubs)
68
73
  .where(and(
69
74
  eq(federatedHubs.status, 'accepted'),
70
75
  eq(federatedHubs.isHidden, false),
71
- or(
72
- isNull(federatedHubs.lastSyncAt),
73
- lt(federatedHubs.lastSyncAt, staleThreshold),
74
- ),
76
+ isStale,
75
77
  ))
76
78
  .limit(MAX_HUBS_PER_CYCLE);
77
79
 
78
- if (staleHubs.length === 0) return;
80
+ if (candidates.length === 0) return;
81
+
82
+ // Map id -> was-this-a-first-sync (prior lastSyncAt null), captured pre-claim.
83
+ const wasFirstSync = new Map<string, boolean>(
84
+ candidates.map((c) => [c.id, c.lastSyncAt === null]),
85
+ );
86
+
87
+ // Atomic claim (mirrors federation-delivery's compare-and-claim): set
88
+ // lastSyncAt = now() on the selected ids, but only WHERE still stale. On N
89
+ // replicas selecting the same hubs, exactly one replica's UPDATE matches the
90
+ // stale predicate per row, so each hub is claimed (and fetched from remotes)
91
+ // once per cycle instead of N times. RETURNING gives us the rows we won.
92
+ const candidateIds = candidates.map((c) => c.id);
93
+ const claimedAt = new Date();
94
+ const staleHubs = await db
95
+ .update(federatedHubs)
96
+ .set({ lastSyncAt: claimedAt })
97
+ .where(and(
98
+ inArray(federatedHubs.id, candidateIds),
99
+ isStale,
100
+ ))
101
+ .returning({
102
+ id: federatedHubs.id,
103
+ actorUri: federatedHubs.actorUri,
104
+ name: federatedHubs.name,
105
+ });
106
+
107
+ if (staleHubs.length === 0) return; // another replica claimed them first
79
108
 
80
- console.log(`[hub-sync] Found ${staleHubs.length} stale hub(s) to sync`);
109
+ console.log(`[hub-sync] Claimed ${staleHubs.length} stale hub(s) to sync`);
81
110
 
82
111
  for (const hub of staleHubs) {
83
112
  try {
@@ -85,7 +114,7 @@ export default defineNitroPlugin((nitro) => {
85
114
  await refreshFederatedHubMetadata(db, hub.id, hub.actorUri);
86
115
 
87
116
  // Fetch followers to populate members list (first sync or periodic refresh)
88
- if (!hub.lastSyncAt) {
117
+ if (wasFirstSync.get(hub.id)) {
89
118
  // First sync — fetch followers to seed the members table
90
119
  try {
91
120
  const result = await fetchRemoteHubFollowers(db, hub.id, domain);
@@ -105,7 +134,9 @@ export default defineNitroPlugin((nitro) => {
105
134
  }
106
135
  }
107
136
 
108
- // Update lastSyncAt
137
+ // Refresh lastSyncAt to completion time. The atomic claim above already
138
+ // set it to the claim time (which is what prevents other replicas from
139
+ // re-claiming); this just advances it to reflect successful completion.
109
140
  await db.update(federatedHubs).set({
110
141
  lastSyncAt: new Date(),
111
142
  }).where(eq(federatedHubs.id, hub.id));
@@ -11,7 +11,7 @@ import {
11
11
  listNotifications,
12
12
  } from '@commonpub/server';
13
13
  import type { NotificationType } from '@commonpub/server';
14
- import { users } from '@commonpub/schema';
14
+ import { users, digestRuns } from '@commonpub/schema';
15
15
  import { and, isNotNull, eq } from 'drizzle-orm';
16
16
 
17
17
  export default defineNitroPlugin((nitro) => {
@@ -76,7 +76,9 @@ export default defineNitroPlugin((nitro) => {
76
76
  }
77
77
  }, 5_000);
78
78
 
79
- // Track the last date digests were sent to prevent duplicates on server restart during 8am hour
79
+ // Cheap in-process pre-check to avoid hitting the DB once this replica has already
80
+ // observed today's digest as claimed. The DB claim (digest_runs) is the authority:
81
+ // it guarantees exactly-one-replica-wins across N replicas / restarts.
80
82
  let lastDigestDate = '';
81
83
 
82
84
  async function runDigest(siteUrl: string, siteName: string): Promise<void> {
@@ -91,11 +93,28 @@ export default defineNitroPlugin((nitro) => {
91
93
 
92
94
  if (!isDigestHour) return;
93
95
 
94
- // Prevent duplicate sends if server restarts during digest hour
96
+ // Deterministic UTC date key (YYYY-MM-DD). toISOString() is always UTC.
95
97
  const todayKey = now.toISOString().slice(0, 10);
98
+
99
+ // Cheap pre-check: this replica already knows today's digest is handled.
96
100
  if (lastDigestDate === todayKey) return;
101
+
102
+ // Atomic cross-replica claim: INSERT today's row, ON CONFLICT DO NOTHING.
103
+ // Exactly one replica gets a returned row (it won the claim); the rest get
104
+ // an empty array and bail without sending. Replaces the per-process guard
105
+ // that sent N duplicate digests on N replicas.
106
+ const claimed = await db
107
+ .insert(digestRuns)
108
+ .values({ digestDate: todayKey })
109
+ .onConflictDoNothing({ target: digestRuns.digestDate })
110
+ .returning({ digestDate: digestRuns.digestDate });
111
+
112
+ // Record locally regardless of outcome so this replica skips the DB on
113
+ // subsequent hourly ticks within the same UTC day.
97
114
  lastDigestDate = todayKey;
98
115
 
116
+ if (claimed.length === 0) return; // another replica already claimed today
117
+
99
118
  // Find users with digest preferences
100
119
  const digestUsers = await db
101
120
  .select({
@@ -1,5 +1,5 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
- import { createInboxHandlers } from '@commonpub/server';
2
+ import { createInboxHandlers, recordActivitySeen } from '@commonpub/server';
3
3
  import { verifyInboxRequest, assertActorMatchesSigner } from '../../../utils/inbox';
4
4
 
5
5
  /**
@@ -22,6 +22,18 @@ export default defineEventHandler(async (event) => {
22
22
  assertActorMatchesSigner(actorUri, body, 'hub-inbox');
23
23
 
24
24
  const db = useDB();
25
+
26
+ // Replay dedup: claim the verified activity id BEFORE dispatch so a replayed,
27
+ // validly-signed activity can't double-apply side effects. No id = process
28
+ // normally. Placed after verification so attacker-chosen ids can't be seeded.
29
+ const activityId = body.id;
30
+ if (typeof activityId === 'string' && activityId.length > 0) {
31
+ const first = await recordActivitySeen(db, activityId);
32
+ if (!first) {
33
+ return { status: 'accepted' };
34
+ }
35
+ }
36
+
25
37
  const domain = config.instance.domain;
26
38
  const slug = getRouterParam(event, 'slug');
27
39
  const handlers = createInboxHandlers({ db, domain, hubContext: slug ? { hubSlug: slug } : undefined });
@@ -1,5 +1,5 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
- import { createInboxHandlers } from '@commonpub/server';
2
+ import { createInboxHandlers, recordActivitySeen } from '@commonpub/server';
3
3
  import { verifyInboxRequest, assertActorMatchesSigner, extractDomain } from '../utils/inbox';
4
4
 
5
5
  export default defineEventHandler(async (event) => {
@@ -18,6 +18,19 @@ export default defineEventHandler(async (event) => {
18
18
  assertActorMatchesSigner(actorUri, body, 'shared-inbox');
19
19
 
20
20
  const db = useDB();
21
+
22
+ // Replay dedup: claim the verified activity id BEFORE dispatch so a replayed,
23
+ // validly-signed activity can't double-apply side effects. No id = process
24
+ // normally (can't dedup what isn't addressable). Placed after verification so
25
+ // attacker-chosen ids can't be seeded.
26
+ const activityId = body.id;
27
+ if (typeof activityId === 'string' && activityId.length > 0) {
28
+ const first = await recordActivitySeen(db, activityId);
29
+ if (!first) {
30
+ return { status: 'accepted' };
31
+ }
32
+ }
33
+
21
34
  const runtimeConfig = useRuntimeConfig();
22
35
  const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
23
36
  const callbacks = createInboxHandlers({ db, domain });
@@ -1,5 +1,5 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
- import { createInboxHandlers } from '@commonpub/server';
2
+ import { createInboxHandlers, recordActivitySeen } from '@commonpub/server';
3
3
  import { verifyInboxRequest, assertActorMatchesSigner, extractDomain } from '../../../utils/inbox';
4
4
 
5
5
  export default defineEventHandler(async (event) => {
@@ -17,6 +17,18 @@ export default defineEventHandler(async (event) => {
17
17
  assertActorMatchesSigner(actorUri, body, 'user-inbox');
18
18
 
19
19
  const db = useDB();
20
+
21
+ // Replay dedup: claim the verified activity id BEFORE dispatch so a replayed,
22
+ // validly-signed activity can't double-apply side effects. No id = process
23
+ // normally. Placed after verification so attacker-chosen ids can't be seeded.
24
+ const activityId = body.id;
25
+ if (typeof activityId === 'string' && activityId.length > 0) {
26
+ const first = await recordActivitySeen(db, activityId);
27
+ if (!first) {
28
+ return { status: 'accepted' };
29
+ }
30
+ }
31
+
20
32
  const runtimeConfig = useRuntimeConfig();
21
33
  const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
22
34
  const callbacks = createInboxHandlers({ db, domain });
@@ -4,6 +4,7 @@
4
4
  * body size limits, and Date header freshness checks.
5
5
  */
6
6
  import { verifyHttpSignature, resolveActor } from '@commonpub/protocol';
7
+ import { createSafeActorFetchFn } from '@commonpub/server';
7
8
  import type { H3Event } from 'h3';
8
9
 
9
10
  /** Maximum allowed body size for inbox POSTs (1 MB) */
@@ -97,8 +98,12 @@ export async function verifyInboxRequest(event: H3Event, label: string): Promise
97
98
 
98
99
  const actorUri = keyId.replace(/#.*$/, '');
99
100
 
100
- // 4. Resolve actor and public key
101
- const actor = await resolveActor(actorUri, fetch);
101
+ // 4. Resolve actor and public key.
102
+ // Use the SSRF-pinned fetch (DNS-rebind safe), NOT raw global fetch: actorUri here is
103
+ // the attacker-controlled keyId resolved BEFORE signature verification, so an
104
+ // unauthenticated POST /inbox could otherwise drive a server-side GET to internal
105
+ // addresses (cloud metadata, RFC1918). Audit session 204 — P0.
106
+ const actor = await resolveActor(actorUri, createSafeActorFetchFn());
102
107
  if (!actor?.publicKey?.publicKeyPem) {
103
108
  throw createError({ statusCode: 401, statusMessage: 'Could not resolve actor public key' });
104
109
  }
@@ -9,6 +9,13 @@ import type { ZodType } from 'zod';
9
9
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
10
  const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*$/;
11
11
 
12
+ /** True when `value` is a syntactically-valid UUID. Use to guard untrusted
13
+ * query/body values that feed a uuid SQL bind (a non-uuid string reaching
14
+ * the bind throws an unhandled 500). Router params should use `parseParams`. */
15
+ export function isUuid(value: string): boolean {
16
+ return UUID_REGEX.test(value);
17
+ }
18
+
12
19
  /**
13
20
  * Hard ceiling on JSON request bodies (10 MB). Every JSON write route funnels
14
21
  * through `parseBody`, so this one guard caps them all. It rejects on the
@@ -33,10 +40,25 @@ type ParamType = 'uuid' | 'slug' | 'string';
33
40
 
34
41
  /** Parse and validate request body against a Zod schema. Throws 400 on failure, 413 if oversized. */
35
42
  export async function parseBody<T>(event: H3Event, schema: ZodType<T>): Promise<T> {
43
+ // Fast-path: reject on the declared Content-Length before buffering anything.
36
44
  const declaredLength = Number(getRequestHeader(event, 'content-length') ?? 0);
37
45
  if (Number.isFinite(declaredLength) && declaredLength > MAX_JSON_BODY_BYTES) {
38
46
  throw createError({ statusCode: 413, statusMessage: 'Payload too large' });
39
47
  }
48
+
49
+ // The header is advisory and absent on chunked (Transfer-Encoding: chunked)
50
+ // requests — a client can stream an unbounded body with no Content-Length and
51
+ // slip past the fast-path. Read the RAW body once and enforce the cap on the
52
+ // ACTUAL buffered byte size before JSON.parse. `readBody` reuses this cached
53
+ // raw body, so there is no double read.
54
+ const raw: string | Buffer | undefined = await readRawBody(event, false);
55
+ if (raw != null) {
56
+ const size = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.length;
57
+ if (size > MAX_JSON_BODY_BYTES) {
58
+ throw createError({ statusCode: 413, statusMessage: 'Payload too large' });
59
+ }
60
+ }
61
+
40
62
  const body = await readBody(event);
41
63
  const parsed = schema.safeParse(body);
42
64
  if (!parsed.success) {
package/theme/base.css CHANGED
@@ -7,6 +7,11 @@
7
7
  =========================================== */
8
8
 
9
9
  :root {
10
+ /* Theme native UI (date/time pickers, scrollbars, form controls) to the light
11
+ palette; dark.css overrides to `dark`. Without this, native popups (e.g. the
12
+ datetime-local calendar) render in the OS default scheme and clash. */
13
+ color-scheme: light;
14
+
10
15
  /* === SURFACES === */
11
16
  --bg: #fafaf9;
12
17
  --surface: #ffffff;
package/theme/prose.css CHANGED
@@ -339,4 +339,24 @@
339
339
  text-transform: uppercase;
340
340
  letter-spacing: var(--tracking-wide);
341
341
  }
342
+
343
+ /* ===========================================
344
+ Full-HTML author content (CpubMarkdown format=html)
345
+ The v-html children carry no scoped styles, so this global, theme-aware
346
+ baseline keeps un-styled (or var()-based) author HTML readable in BOTH
347
+ themes. Hardcoded color literals are neutralized at sanitize time
348
+ (sanitizeRichHtml neutralizeColors), so this baseline shows through.
349
+ =========================================== */
350
+ .cpub-md-html {
351
+ color: var(--text);
352
+ line-height: var(--leading-normal);
353
+ }
354
+ .cpub-md-html a { color: var(--accent); }
355
+ .cpub-md-html h1, .cpub-md-html h2, .cpub-md-html h3,
356
+ .cpub-md-html h4, .cpub-md-html h5, .cpub-md-html h6 { color: var(--text); }
357
+ .cpub-md-html img { max-width: 100%; height: auto; }
358
+ .cpub-md-html hr { border: 0; border-top: var(--border-width-default) solid var(--border); }
359
+ .cpub-md-html blockquote { border-left: 3px solid var(--border); color: var(--text-dim); padding-left: var(--space-4); }
360
+ .cpub-md-html th, .cpub-md-html td { border: var(--border-width-default) solid var(--border); }
361
+ .cpub-md-html pre, .cpub-md-html code { background: var(--surface2); color: var(--text); }
342
362
  }
@@ -11,6 +11,10 @@
11
11
  =========================================== */
12
12
 
13
13
  [data-theme="stoa-dark"] {
14
+ /* Theme native UI (date/time pickers, scrollbars) dark — dark.css/agora-dark.css
15
+ already do this; stoa-dark was missing it, so native controls rendered light. */
16
+ color-scheme: dark;
17
+
14
18
  /* === SURFACES (warm near-black) === */
15
19
  --bg: #15130d;
16
20
  --surface: #1f1c14;