@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
|
@@ -2,8 +2,22 @@ import { contentItems, hubs, hubPosts } from '@commonpub/schema';
|
|
|
2
2
|
import { federateContent, federateHubPost, federateHubActor } from '@commonpub/server';
|
|
3
3
|
import { eq, and, gte, desc, isNull } from 'drizzle-orm';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
5
6
|
import { extractDomain } from '../../../utils/inbox';
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Constant-time secret comparison. `===` on the raw strings short-circuits at the
|
|
10
|
+
* first differing byte, leaking the secret's length and a per-character timing
|
|
11
|
+
* oracle on the signing secret. We SHA-256 both sides to a fixed 32-byte digest
|
|
12
|
+
* first (so neither length nor content leaks via timing), then compare the digests
|
|
13
|
+
* with `timingSafeEqual` (which requires equal-length buffers — always true here).
|
|
14
|
+
*/
|
|
15
|
+
function secretsMatch(a: string, b: string): boolean {
|
|
16
|
+
const da = createHash('sha256').update(a).digest();
|
|
17
|
+
const db = createHash('sha256').update(b).digest();
|
|
18
|
+
return timingSafeEqual(da, db);
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
/** Default re-federation window when neither `all` nor `since` is given — avoids a delivery storm. */
|
|
8
22
|
const DEFAULT_SINCE_DAYS = 30;
|
|
9
23
|
/** Hard cap on a bounded (non-`all`) re-federation run. */
|
|
@@ -27,7 +41,10 @@ export default defineEventHandler(async (event) => {
|
|
|
27
41
|
// Allow CLI trigger via AUTH_SECRET header (for server-side automation)
|
|
28
42
|
const cliSecret = getRequestHeader(event, 'x-admin-secret');
|
|
29
43
|
const runtimeConfig = useRuntimeConfig();
|
|
30
|
-
|
|
44
|
+
const authSecret = runtimeConfig.authSecret as string | undefined;
|
|
45
|
+
// Fail-closed: only accept the shared-secret bypass when BOTH the header and a
|
|
46
|
+
// configured server secret are present, compared in constant time.
|
|
47
|
+
if (cliSecret && authSecret && secretsMatch(cliSecret, authSecret)) {
|
|
31
48
|
// Authorized via shared secret
|
|
32
49
|
} else {
|
|
33
50
|
requirePermission(event, 'federation.manage');
|
|
@@ -17,10 +17,7 @@ export default defineEventHandler(async (event) => {
|
|
|
17
17
|
const admin = requirePermission(event, 'layout.manage');
|
|
18
18
|
const db = useDB();
|
|
19
19
|
|
|
20
|
-
const id =
|
|
21
|
-
if (!id) {
|
|
22
|
-
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
23
|
-
}
|
|
20
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
24
21
|
|
|
25
22
|
const existing = await getLayoutById(db, id);
|
|
26
23
|
if (!existing) {
|
|
@@ -18,11 +18,7 @@ export default defineEventHandler(async (event) => {
|
|
|
18
18
|
const admin = requirePermission(event, 'layout.manage');
|
|
19
19
|
const db = useDB();
|
|
20
20
|
|
|
21
|
-
const id =
|
|
22
|
-
const versionId = getRouterParam(event, 'versionId');
|
|
23
|
-
if (!id || !versionId) {
|
|
24
|
-
throw createError({ statusCode: 400, statusMessage: 'Missing id or versionId param' });
|
|
25
|
-
}
|
|
21
|
+
const { id, versionId } = parseParams(event, { id: 'uuid', versionId: 'uuid' });
|
|
26
22
|
|
|
27
23
|
const existing = await getLayoutById(db, id);
|
|
28
24
|
if (!existing) {
|
|
@@ -15,10 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
15
15
|
requirePermission(event, 'layout.manage');
|
|
16
16
|
const db = useDB();
|
|
17
17
|
|
|
18
|
-
const id =
|
|
19
|
-
if (!id) {
|
|
20
|
-
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
21
|
-
}
|
|
18
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
22
19
|
|
|
23
20
|
const existing = await getLayoutById(db, id);
|
|
24
21
|
if (!existing) {
|
|
@@ -18,10 +18,7 @@ export default defineEventHandler(async (event): Promise<{ ok: true; id: string
|
|
|
18
18
|
const admin = requirePermission(event, 'layout.manage');
|
|
19
19
|
const db = useDB();
|
|
20
20
|
|
|
21
|
-
const id =
|
|
22
|
-
if (!id) {
|
|
23
|
-
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
24
|
-
}
|
|
21
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
25
22
|
|
|
26
23
|
const existing = await getLayoutById(db, id);
|
|
27
24
|
if (!existing) {
|
|
@@ -13,10 +13,7 @@ export default defineEventHandler(async (event) => {
|
|
|
13
13
|
requireFeature('layoutEngine');
|
|
14
14
|
requirePermission(event, 'layout.manage');
|
|
15
15
|
|
|
16
|
-
const id =
|
|
17
|
-
if (!id) {
|
|
18
|
-
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
19
|
-
}
|
|
16
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
20
17
|
|
|
21
18
|
const db = useDB();
|
|
22
19
|
const layout = await getLayoutById(db, id);
|
|
@@ -30,10 +30,7 @@ export default defineEventHandler(async (event) => {
|
|
|
30
30
|
const admin = requirePermission(event, 'layout.manage');
|
|
31
31
|
const db = useDB();
|
|
32
32
|
|
|
33
|
-
const id =
|
|
34
|
-
if (!id) {
|
|
35
|
-
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
36
|
-
}
|
|
33
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
37
34
|
|
|
38
35
|
const existing = await getLayoutById(db, id);
|
|
39
36
|
if (!existing) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PERMISSIONS } from '@commonpub/schema';
|
|
2
|
+
|
|
3
|
+
// The grantable permission catalog — drives the role editor's checkbox list.
|
|
4
|
+
// `*` and `admin.access` are the admin bypass: they're reserved to the `admin`
|
|
5
|
+
// system role and stripped from every other role server-side (see
|
|
6
|
+
// rbac/admin.ts sanitizeGrants), so we don't offer them in the editor (a
|
|
7
|
+
// checkbox for them would silently never stick). Gated on `roles.manage`.
|
|
8
|
+
const ADMIN_BYPASS_GRANTS = new Set(['*', 'admin.access']);
|
|
9
|
+
|
|
10
|
+
export default defineEventHandler((event): string[] => {
|
|
11
|
+
requireFeature('admin');
|
|
12
|
+
requirePermission(event, 'roles.manage');
|
|
13
|
+
return PERMISSIONS.filter((p) => !ADMIN_BYPASS_GRANTS.has(p));
|
|
14
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { deleteRole } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
// Delete a custom role (system roles are protected). Cascades its grants +
|
|
4
|
+
// user assignments via the FK.
|
|
5
|
+
export default defineEventHandler(async (event): Promise<{ deleted: true }> => {
|
|
6
|
+
requireFeature('admin');
|
|
7
|
+
requirePermission(event, 'roles.manage');
|
|
8
|
+
const db = useDB();
|
|
9
|
+
const actor = requireAuth(event);
|
|
10
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await deleteRole(db, id, actor.id);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (err instanceof Error && err.message === 'ROLE_NOT_FOUND') {
|
|
16
|
+
throw createError({ statusCode: 404, statusMessage: 'Role not found' });
|
|
17
|
+
}
|
|
18
|
+
if (err instanceof Error && err.message === 'ROLE_IS_SYSTEM') {
|
|
19
|
+
throw createError({ statusCode: 400, statusMessage: 'System roles cannot be deleted' });
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
invalidateAllPermissions();
|
|
24
|
+
return { deleted: true };
|
|
25
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { updateRole } from '@commonpub/server';
|
|
2
|
+
import { updateRoleSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
// Edit a role's name/description/permissions (system roles included — e.g. tune
|
|
5
|
+
// the staff moderator set). The admin role always keeps its `*` bypass.
|
|
6
|
+
export default defineEventHandler(async (event): Promise<{ ok: true }> => {
|
|
7
|
+
requireFeature('admin');
|
|
8
|
+
requirePermission(event, 'roles.manage');
|
|
9
|
+
const db = useDB();
|
|
10
|
+
const actor = requireAuth(event);
|
|
11
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
12
|
+
const input = await parseBody(event, updateRoleSchema);
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await updateRole(db, id, input, actor.id);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err instanceof Error && err.message === 'ROLE_NOT_FOUND') {
|
|
18
|
+
throw createError({ statusCode: 404, statusMessage: 'Role not found' });
|
|
19
|
+
}
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
invalidateAllPermissions();
|
|
23
|
+
return { ok: true };
|
|
24
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { listRolesWithPermissions } from '@commonpub/server';
|
|
2
|
+
import type { RoleWithPermissions } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
// List all roles with their permission keys + member counts. Gated on
|
|
5
|
+
// `roles.manage` (RBAC self-administration).
|
|
6
|
+
export default defineEventHandler(async (event): Promise<RoleWithPermissions[]> => {
|
|
7
|
+
requireFeature('admin');
|
|
8
|
+
requirePermission(event, 'roles.manage');
|
|
9
|
+
return listRolesWithPermissions(useDB());
|
|
10
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createRole } from '@commonpub/server';
|
|
2
|
+
import { createRoleSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
// Create a custom role. Gated on `roles.manage`. New grants take effect on the
|
|
5
|
+
// next request (≤ cache TTL); invalidate the whole cache to be immediate.
|
|
6
|
+
export default defineEventHandler(async (event): Promise<{ id: string }> => {
|
|
7
|
+
requireFeature('admin');
|
|
8
|
+
requirePermission(event, 'roles.manage');
|
|
9
|
+
const db = useDB();
|
|
10
|
+
const actor = requireAuth(event);
|
|
11
|
+
const input = await parseBody(event, createRoleSchema);
|
|
12
|
+
|
|
13
|
+
let result;
|
|
14
|
+
try {
|
|
15
|
+
result = await createRole(db, input, actor.id);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err instanceof Error && err.message === 'ROLE_KEY_TAKEN') {
|
|
18
|
+
throw createError({ statusCode: 409, statusMessage: 'A role with that key already exists' });
|
|
19
|
+
}
|
|
20
|
+
if (err instanceof Error && err.message === 'ROLE_KEY_RESERVED') {
|
|
21
|
+
throw createError({ statusCode: 400, statusMessage: 'That key is reserved for a system role' });
|
|
22
|
+
}
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
invalidateAllPermissions();
|
|
26
|
+
return result;
|
|
27
|
+
});
|
|
@@ -8,5 +8,24 @@ export default defineEventHandler(async (event): Promise<void> => {
|
|
|
8
8
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
9
9
|
const input = await parseBody(event, adminUpdateRoleSchema);
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Minting an admin is admin-only: `users.manage` lets a custom role manage
|
|
12
|
+
// non-admin users, but must not be a backdoor to creating admins (which would
|
|
13
|
+
// turn `users.manage` into root). Promotion to the admin system role requires
|
|
14
|
+
// the admin floor itself.
|
|
15
|
+
if (input.role === 'admin') requirePermission(event, 'admin.access');
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await updateUserRole(db, id, input.role, admin.id);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err instanceof Error && err.message === 'LAST_ADMIN') {
|
|
21
|
+
throw createError({ statusCode: 400, statusMessage: 'Cannot demote the only admin account' });
|
|
22
|
+
}
|
|
23
|
+
if (err instanceof Error && err.message === 'User not found') {
|
|
24
|
+
throw createError({ statusCode: 404, statusMessage: 'User not found' });
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
// The role change alters effective permissions — drop the cached set so the
|
|
29
|
+
// next request resolves fresh (cache lives in the layer; commit happened above).
|
|
30
|
+
invalidatePermissions(id);
|
|
12
31
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getUserRoleIds } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
// The role IDs a user currently holds (system + custom) — for the admin UI's
|
|
4
|
+
// role assignment checkboxes.
|
|
5
|
+
export default defineEventHandler(async (event): Promise<{ roleIds: string[] }> => {
|
|
6
|
+
requireFeature('admin');
|
|
7
|
+
requirePermission(event, 'roles.manage');
|
|
8
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
9
|
+
return { roleIds: await getUserRoleIds(useDB(), id) };
|
|
10
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setUserCustomRoles } from '@commonpub/server';
|
|
2
|
+
import { setUserRolesSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
// Replace a user's CUSTOM (non-system) role assignments. The system/primary role
|
|
5
|
+
// is managed separately via role.put.ts; system role IDs here are ignored.
|
|
6
|
+
export default defineEventHandler(async (event): Promise<{ ok: true }> => {
|
|
7
|
+
requireFeature('admin');
|
|
8
|
+
requirePermission(event, 'roles.manage');
|
|
9
|
+
const db = useDB();
|
|
10
|
+
const actor = requireAuth(event);
|
|
11
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
12
|
+
const input = await parseBody(event, setUserRolesSchema);
|
|
13
|
+
|
|
14
|
+
await setUserCustomRoles(db, id, input.roleIds, actor.id);
|
|
15
|
+
invalidatePermissions(id);
|
|
16
|
+
return { ok: true };
|
|
17
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { discoverOAuthEndpoint } from '@commonpub/auth';
|
|
2
|
-
import { storeOAuthState, isDomainTrusted } from '@commonpub/server';
|
|
2
|
+
import { storeOAuthState, isDomainTrusted, createSafeActorFetchFn } from '@commonpub/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
5
|
const loginSchema = z.object({
|
|
@@ -27,7 +27,10 @@ export default defineEventHandler(async (event) => {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// SSRF-safe fetch (pinned dispatcher): even a trusted domain's WebFinger/registration
|
|
31
|
+
// response must not be able to drive a request to an internal address (audit session 204).
|
|
32
|
+
const safeFetch = createSafeActorFetchFn();
|
|
33
|
+
const endpoints = await discoverOAuthEndpoint(instanceDomain, 'instance', safeFetch as unknown as typeof fetch);
|
|
31
34
|
if (!endpoints) {
|
|
32
35
|
throw createError({
|
|
33
36
|
statusCode: 502,
|
|
@@ -43,15 +46,19 @@ export default defineEventHandler(async (event) => {
|
|
|
43
46
|
if (!clientId) {
|
|
44
47
|
try {
|
|
45
48
|
const regUrl = endpoints.tokenEndpoint.replace('/token', '/register');
|
|
46
|
-
|
|
49
|
+
// regUrl is derived from the remote's discovery response — route the POST through
|
|
50
|
+
// the SSRF-safe fetch so it can't be pointed at an internal address.
|
|
51
|
+
const regRes = await safeFetch(regUrl, {
|
|
47
52
|
method: 'POST',
|
|
48
|
-
|
|
53
|
+
headers: { 'content-type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({
|
|
49
55
|
client_name: config.instance.name || config.instance.domain,
|
|
50
56
|
redirect_uris: [redirectUri],
|
|
51
57
|
client_uri: `https://${config.instance.domain}`,
|
|
52
58
|
instance_domain: config.instance.domain,
|
|
53
|
-
},
|
|
59
|
+
}),
|
|
54
60
|
});
|
|
61
|
+
const regResult = (await regRes.json()) as { client_id: string; client_secret: string };
|
|
55
62
|
effectiveClientId = regResult.client_id;
|
|
56
63
|
effectiveClientSecret = regResult.client_secret;
|
|
57
64
|
} catch {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth-gating proving test for `GET /api/content/:id/versions`
|
|
3
|
+
* (audit session 204).
|
|
4
|
+
*
|
|
5
|
+
* Version history (titles, author, timestamps — incl. for unpublished drafts)
|
|
6
|
+
* was world-readable for any content id. The fix requires an authenticated
|
|
7
|
+
* caller AND that they be the content owner OR hold `content.moderate`.
|
|
8
|
+
*
|
|
9
|
+
* This drives the REAL route handler against a REAL (PGlite) DB seeded with a
|
|
10
|
+
* content item + a version row. The Nitro auth auto-imports are stubbed with
|
|
11
|
+
* their REAL semantics (requireAuth throws 401 when anonymous; ownerOrPermission
|
|
12
|
+
* returns true iff the caller is the owner or holds the permission), reading a
|
|
13
|
+
* per-test `currentUser` — so the handler's actual branch on the gate boolean
|
|
14
|
+
* and the DB row's authorId is exercised.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
17
|
+
import type { H3Event } from 'h3';
|
|
18
|
+
import {
|
|
19
|
+
createTestDB,
|
|
20
|
+
createTestUser,
|
|
21
|
+
} from '../../../../../../../packages/server/src/__tests__/helpers/testdb';
|
|
22
|
+
import { contentItems, contentVersions } from '@commonpub/schema';
|
|
23
|
+
import type { DB } from '../../../../../../../packages/server/src/types';
|
|
24
|
+
|
|
25
|
+
interface HttpError extends Error {
|
|
26
|
+
statusCode: number;
|
|
27
|
+
}
|
|
28
|
+
interface TestUser {
|
|
29
|
+
id: string;
|
|
30
|
+
permissions: Set<string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let db: DB;
|
|
34
|
+
let contentId: string;
|
|
35
|
+
let ownerId: string;
|
|
36
|
+
// null => anonymous. permissions => the set the caller holds.
|
|
37
|
+
let currentUser: TestUser | null;
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
const g = globalThis as Record<string, unknown>;
|
|
41
|
+
g.defineEventHandler = (fn: unknown): unknown => fn;
|
|
42
|
+
g.createError = (opts: { statusCode: number; statusMessage: string }): HttpError => {
|
|
43
|
+
const e = new Error(opts.statusMessage) as HttpError;
|
|
44
|
+
e.statusCode = opts.statusCode;
|
|
45
|
+
return e;
|
|
46
|
+
};
|
|
47
|
+
// Real semantics: 401 when there is no authenticated user.
|
|
48
|
+
g.requireAuth = (_event: H3Event): { id: string } => {
|
|
49
|
+
if (!currentUser) {
|
|
50
|
+
const e = new Error('Unauthorized') as HttpError;
|
|
51
|
+
e.statusCode = 401;
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
return { id: currentUser.id };
|
|
55
|
+
};
|
|
56
|
+
g.useDB = (): DB => db;
|
|
57
|
+
// parseParams returns the id under test (route would parse from the path).
|
|
58
|
+
g.parseParams = (): { id: string } => ({ id: contentId });
|
|
59
|
+
// Real semantics: owner OR permission-holder.
|
|
60
|
+
g.ownerOrPermission = (_event: H3Event, resourceOwnerId: string, perm: string): boolean => {
|
|
61
|
+
if (!currentUser) return false;
|
|
62
|
+
if (currentUser.id === resourceOwnerId) return true;
|
|
63
|
+
return currentUser.permissions.has(perm);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handlerMod = await import('../versions.get');
|
|
68
|
+
const handler = handlerMod.default as (event: H3Event) => Promise<unknown>;
|
|
69
|
+
const fakeEvent = {} as H3Event;
|
|
70
|
+
|
|
71
|
+
function statusOf(p: Promise<unknown>): Promise<number | 'no-throw'> {
|
|
72
|
+
return p.then(
|
|
73
|
+
() => 'no-throw' as const,
|
|
74
|
+
(e: HttpError) => e.statusCode,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
db = await createTestDB();
|
|
80
|
+
const owner = await createTestUser(db, { username: 'owner' });
|
|
81
|
+
ownerId = owner.id;
|
|
82
|
+
const [item] = await db
|
|
83
|
+
.insert(contentItems)
|
|
84
|
+
.values({
|
|
85
|
+
authorId: owner.id,
|
|
86
|
+
type: 'blog',
|
|
87
|
+
title: 'Draft',
|
|
88
|
+
slug: 'draft',
|
|
89
|
+
status: 'draft',
|
|
90
|
+
visibility: 'private',
|
|
91
|
+
content: [],
|
|
92
|
+
} as never)
|
|
93
|
+
.returning();
|
|
94
|
+
contentId = (item as { id: string }).id;
|
|
95
|
+
await db.insert(contentVersions).values({
|
|
96
|
+
contentId,
|
|
97
|
+
version: 1,
|
|
98
|
+
title: 'Draft v1',
|
|
99
|
+
createdById: owner.id,
|
|
100
|
+
} as never);
|
|
101
|
+
}, 30_000); // PGlite pushSchema is heavy; generous setup timeout under parallel load
|
|
102
|
+
|
|
103
|
+
describe('versions.get — author/moderator-only', () => {
|
|
104
|
+
it('anonymous caller → 401', async () => {
|
|
105
|
+
currentUser = null;
|
|
106
|
+
expect(await statusOf(handler(fakeEvent))).toBe(401);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('authenticated non-owner without content.moderate → 403', async () => {
|
|
110
|
+
currentUser = { id: 'some-other-user-id', permissions: new Set() };
|
|
111
|
+
expect(await statusOf(handler(fakeEvent))).toBe(403);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('owner → returns the version list', async () => {
|
|
115
|
+
currentUser = { id: ownerId, permissions: new Set() };
|
|
116
|
+
const result = (await handler(fakeEvent)) as Array<{ title: string | null }>;
|
|
117
|
+
expect(Array.isArray(result)).toBe(true);
|
|
118
|
+
expect(result.length).toBe(1);
|
|
119
|
+
expect(result[0]?.title).toBe('Draft v1');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('non-owner WITH content.moderate → returns the version list', async () => {
|
|
123
|
+
currentUser = { id: 'mod-user-id', permissions: new Set(['content.moderate']) };
|
|
124
|
+
const result = (await handler(fakeEvent)) as unknown[];
|
|
125
|
+
expect(result.length).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { isBuildMarked } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
// Hydration counterpart to build.post.ts (which TOGGLES): lets the content view
|
|
4
|
+
// show the correct "I Built This" state on load, so a reload doesn't render the
|
|
5
|
+
// button inactive and a re-click doesn't silently un-mark + decrement the count.
|
|
6
|
+
export default defineEventHandler(async (event): Promise<{ marked: boolean }> => {
|
|
7
|
+
const user = requireAuth(event);
|
|
8
|
+
const db = useDB();
|
|
9
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
10
|
+
return { marked: await isBuildMarked(db, id, user.id) };
|
|
11
|
+
});
|
|
@@ -2,6 +2,8 @@ import { createReport } from '@commonpub/server';
|
|
|
2
2
|
import { createReportSchema } from '@commonpub/schema';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<{ id: string }> => {
|
|
5
|
+
// NOTE: abuse reporting is a safety function and deliberately NOT gated behind the
|
|
6
|
+
// `social` feature flag (it must work even where social interactions are off). (session 204)
|
|
5
7
|
const db = useDB();
|
|
6
8
|
const user = requireAuth(event);
|
|
7
9
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { listContentVersions } from '@commonpub/server';
|
|
2
2
|
import type { ContentVersionItem } from '@commonpub/server';
|
|
3
|
+
import { contentItems } from '@commonpub/schema';
|
|
4
|
+
import { eq } from 'drizzle-orm';
|
|
3
5
|
|
|
4
6
|
export default defineEventHandler(async (event): Promise<ContentVersionItem[]> => {
|
|
7
|
+
requireAuth(event);
|
|
5
8
|
const db = useDB();
|
|
6
9
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
7
10
|
|
|
11
|
+
// Version history (titles, author, timestamps, incl. for unpublished drafts) is
|
|
12
|
+
// author/moderator-only — was world-readable for any content id (audit session 204).
|
|
13
|
+
const [row] = await db
|
|
14
|
+
.select({ authorId: contentItems.authorId })
|
|
15
|
+
.from(contentItems)
|
|
16
|
+
.where(eq(contentItems.id, id))
|
|
17
|
+
.limit(1);
|
|
18
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: 'Content not found' });
|
|
19
|
+
if (!ownerOrPermission(event, row.authorId, 'content.moderate')) {
|
|
20
|
+
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
return listContentVersions(db, id);
|
|
9
24
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { getContestBySlug, advanceContestStage } from '@commonpub/server';
|
|
1
|
+
import { getContestBySlug, advanceContestStage, isContestEditor } from '@commonpub/server';
|
|
2
2
|
import { contestAdvanceSchema } from '@commonpub/schema';
|
|
3
3
|
|
|
4
4
|
// Phase B2 — apply an advancement cut at a review stage (cull the cohort to top-N
|
|
5
|
-
// or a manual pick, snapshot scores, advance to the next stage).
|
|
5
|
+
// or a manual pick, snapshot scores, advance to the next stage). Authorized for
|
|
6
|
+
// the owner, a per-contest `editor`, or a `contest.manage` holder.
|
|
6
7
|
export default defineEventHandler(async (event): Promise<{ advanced: boolean; advancedCount: number; eliminatedCount: number }> => {
|
|
7
8
|
requireFeature('contests');
|
|
8
9
|
const db = useDB();
|
|
@@ -13,10 +14,14 @@ export default defineEventHandler(async (event): Promise<{ advanced: boolean; ad
|
|
|
13
14
|
const contest = await getContestBySlug(db, slug);
|
|
14
15
|
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
+
const canManage =
|
|
18
|
+
ownerOrPermission(event, contest.createdById, 'contest.manage') ||
|
|
19
|
+
(await isContestEditor(db, contest.id, user.id));
|
|
20
|
+
|
|
21
|
+
const result = await advanceContestStage(db, contest.id, user.id, input, canManage);
|
|
17
22
|
if (!result.advanced) {
|
|
18
|
-
const
|
|
19
|
-
throw createError({ statusCode:
|
|
23
|
+
const denied = /authoriz|owner/i.test(result.error ?? '');
|
|
24
|
+
throw createError({ statusCode: denied ? 403 : 400, statusMessage: result.error || 'Advancement failed' });
|
|
20
25
|
}
|
|
21
26
|
return result;
|
|
22
27
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getContestBySlug, getContestEntry, canViewContest, getEntryPrivateData } from '@commonpub/server';
|
|
2
|
+
import type { EntryPrivateData } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/contests/:slug/entries/:entryId/private
|
|
6
|
+
* Entrant PII + agreement acceptances for one entry. This is the ONLY way to
|
|
7
|
+
* read PII — it never travels through the normal entries endpoints. Gated by the
|
|
8
|
+
* `contest.pii` permission (admin; staff/others only when RBAC is enabled and the
|
|
9
|
+
* grant is assigned) OR the requester being the entrant (own PII). Returns an empty
|
|
10
|
+
* shape when the entry has no stored PII/agreements.
|
|
11
|
+
*/
|
|
12
|
+
export default defineEventHandler(async (event): Promise<EntryPrivateData> => {
|
|
13
|
+
requireFeature('contests');
|
|
14
|
+
const user = requireAuth(event);
|
|
15
|
+
const db = useDB();
|
|
16
|
+
// PII response — never cache it (browser HTTP cache, bfcache, or any intermediary).
|
|
17
|
+
setHeader(event, 'Cache-Control', 'no-store');
|
|
18
|
+
const { slug, entryId } = parseParams(event, { slug: 'string', entryId: 'uuid' });
|
|
19
|
+
|
|
20
|
+
const contest = await getContestBySlug(db, slug);
|
|
21
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
22
|
+
if (!(await canViewContest(db, contest, user))) {
|
|
23
|
+
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const entry = await getContestEntry(db, entryId);
|
|
27
|
+
if (!entry || entry.contestId !== contest.id) {
|
|
28
|
+
throw createError({ statusCode: 404, statusMessage: 'Entry not found' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Authz: the entrant reads their own PII; everyone else needs contest.pii.
|
|
32
|
+
const isEntrant = user.id === entry.userId;
|
|
33
|
+
if (!isEntrant && !hasPermission(event, 'contest.pii')) {
|
|
34
|
+
throw createError({ statusCode: 403, statusMessage: 'You do not have access to entrant personal data' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const data = await getEntryPrivateData(db, entryId);
|
|
38
|
+
// Always anchor contestId/userId to the validated context (getEntryPrivateData
|
|
39
|
+
// can't fill them when an entry has agreements but no stored PII row).
|
|
40
|
+
return {
|
|
41
|
+
contestId: contest.id,
|
|
42
|
+
entryId,
|
|
43
|
+
userId: entry.userId,
|
|
44
|
+
fields: data?.fields ?? {},
|
|
45
|
+
updatedAt: data?.updatedAt ?? new Date(0),
|
|
46
|
+
agreements: data?.agreements ?? [],
|
|
47
|
+
};
|
|
48
|
+
});
|
|
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event): Promise<{ submitted: boolean; s
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const input = await parseBody(event, stageSubmissionSchema);
|
|
30
|
-
const result = await submitStageArtifact(db, entryId, input.stageId, input.fields, user.id);
|
|
30
|
+
const result = await submitStageArtifact(db, entryId, input.stageId, input.fields, user.id, getRequestIP(event) ?? null);
|
|
31
31
|
if (!result.submitted) {
|
|
32
32
|
throw createError({ statusCode: 400, statusMessage: result.error ?? 'Could not submit' });
|
|
33
33
|
}
|
|
@@ -8,8 +8,7 @@ export default defineEventHandler(async (event) => {
|
|
|
8
8
|
requireFeature('contests');
|
|
9
9
|
const user = requireAuth(event);
|
|
10
10
|
const db = useDB();
|
|
11
|
-
const entryId =
|
|
12
|
-
if (!entryId) throw createError({ statusCode: 400, statusMessage: 'Missing entryId' });
|
|
11
|
+
const { entryId } = parseParams(event, { entryId: 'uuid' });
|
|
13
12
|
|
|
14
13
|
const removed = await removeContestEntryVote(db, entryId, user.id);
|
|
15
14
|
if (!removed) {
|
|
@@ -8,8 +8,7 @@ export default defineEventHandler(async (event) => {
|
|
|
8
8
|
requireFeature('contests');
|
|
9
9
|
const user = requireAuth(event);
|
|
10
10
|
const db = useDB();
|
|
11
|
-
const entryId =
|
|
12
|
-
if (!entryId) throw createError({ statusCode: 400, statusMessage: 'Missing entryId' });
|
|
11
|
+
const { entryId } = parseParams(event, { entryId: 'uuid' });
|
|
13
12
|
|
|
14
13
|
const result = await voteOnContestEntry(db, entryId, user.id);
|
|
15
14
|
if (!result.voted) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { getContestBySlug, canViewContest, isContestEditor, isContestJudge, buildContestExport } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/contests/:slug/export (CSV)
|
|
5
|
+
* The offline-judging spreadsheet: one row per entry (all of them, not the 100
|
|
6
|
+
* cap), one empty column per rubric criterion for manual tallying. PII columns
|
|
7
|
+
* are included ONLY when the requester holds `contest.pii`. Gated to the contest
|
|
8
|
+
* owner / a `contest.manage` holder / a per-contest editor / an accepted judge.
|
|
9
|
+
*/
|
|
10
|
+
export default defineEventHandler(async (event): Promise<string> => {
|
|
11
|
+
requireFeature('contests');
|
|
12
|
+
const user = requireAuth(event);
|
|
13
|
+
const db = useDB();
|
|
14
|
+
const { slug } = parseParams(event, { slug: 'string' });
|
|
15
|
+
|
|
16
|
+
const contest = await getContestBySlug(db, slug);
|
|
17
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
18
|
+
if (!(await canViewContest(db, contest, user))) {
|
|
19
|
+
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const canManage =
|
|
23
|
+
user.id === contest.createdById ||
|
|
24
|
+
hasPermission(event, 'contest.manage') ||
|
|
25
|
+
(await isContestEditor(db, contest.id, user.id));
|
|
26
|
+
const isJudge = await isContestJudge(db, contest.id, user.id);
|
|
27
|
+
if (!canManage && !isJudge) {
|
|
28
|
+
throw createError({ statusCode: 403, statusMessage: 'You cannot export this contest' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// PII columns only for `contest.pii` holders (admin, or an assigned grant when
|
|
32
|
+
// RBAC is enabled), never plain judges or non-admin owners.
|
|
33
|
+
const includePii = hasPermission(event, 'contest.pii');
|
|
34
|
+
const result = await buildContestExport(db, contest.id, includePii);
|
|
35
|
+
if (!result) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
36
|
+
|
|
37
|
+
setHeader(event, 'Content-Type', 'text/csv; charset=utf-8');
|
|
38
|
+
setHeader(event, 'Content-Disposition', `attachment; filename="${result.filename}"`);
|
|
39
|
+
// The export can carry PII columns — never cache it anywhere.
|
|
40
|
+
setHeader(event, 'Cache-Control', 'no-store');
|
|
41
|
+
// UTF-8 BOM so Excel detects the encoding.
|
|
42
|
+
return `${result.csv}`;
|
|
43
|
+
});
|