@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.
- package/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +11 -3
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +54 -20
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useAuth.ts +13 -0
- package/composables/useCan.ts +23 -0
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +43 -18
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +8 -8
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/roles.vue +286 -0
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +81 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -764
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +97 -8
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/admin/permissions.get.ts +14 -0
- package/server/api/admin/roles/[id]/index.delete.ts +25 -0
- package/server/api/admin/roles/[id]/index.put.ts +24 -0
- package/server/api/admin/roles/index.get.ts +10 -0
- package/server/api/admin/roles/index.post.ts +27 -0
- package/server/api/admin/users/[id]/role.put.ts +20 -1
- package/server/api/admin/users/[id]/roles.get.ts +10 -0
- package/server/api/admin/users/[id]/roles.put.ts +17 -0
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/advance.post.ts +10 -5
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/index.get.ts +10 -2
- package/server/api/contests/[slug]/index.put.ts +11 -2
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
- package/server/api/contests/[slug]/transition.post.ts +8 -3
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/me.get.ts +7 -0
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- 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' });
|
|
@@ -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 {
|
|
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<
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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 (
|
|
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]
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 });
|
package/server/routes/inbox.ts
CHANGED
|
@@ -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 });
|
package/server/utils/inbox.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/server/utils/validate.ts
CHANGED
|
@@ -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
|
}
|
package/theme/stoa-dark.css
CHANGED
|
@@ -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;
|