@commonpub/layer 0.82.0 → 0.83.1
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 +8 -2
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +3 -2
- 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/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 +41 -19
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +9 -9
- 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/settings.vue +2 -1
- package/pages/admin/users.vue +1 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -769
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +93 -7
- 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/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]/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]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- 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/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
|
@@ -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
|
|
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 =
|
|
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
|
-
//
|
|
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 || !
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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' });
|
|
@@ -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
|
+
});
|