diff --git a/apps/cms/.env.example b/apps/cms/.env.example index ea12fc37..908694c1 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -17,6 +17,18 @@ ENCRYPTION_KEY=tobemodified BETTER_AUTH_DEBUG=false BETTER_AUTH_SECRET=tobemodified BETTER_AUTH_URL=tobemodified +# Authenticates the better-auth "dash" dashboard integration (lib/auth.ts). +# Required — there is no insecure fallback if this is left unset. +BETTER_AUTH_DASHBOARD_SECRET=tobemodified + +# Social sign-in for the community hub (better-auth). Distinct from the +# "Strapi SSO" GOOGLE_CLIENT_ID/SECRET below, which authenticate the Strapi +# admin panel — these need their own OAuth app(s) with redirect URIs set to +# {BETTER_AUTH_URL}/api/auth/callback/google and .../callback/github. +BETTER_AUTH_GOOGLE_CLIENT_ID=tobemodified +BETTER_AUTH_GOOGLE_CLIENT_SECRET=tobemodified +BETTER_AUTH_GITHUB_CLIENT_ID=tobemodified +BETTER_AUTH_GITHUB_CLIENT_SECRET=tobemodified WEBSITE_URL=http://localhost:3000 SITE_NAME=Strapi Community diff --git a/apps/cms/config/middlewares.ts b/apps/cms/config/middlewares.ts index e31d7fbd..1a4cd1b6 100644 --- a/apps/cms/config/middlewares.ts +++ b/apps/cms/config/middlewares.ts @@ -1,4 +1,4 @@ -export default [ +export default ({ env }) => [ "strapi::logger", "strapi::errors", { @@ -27,7 +27,22 @@ export default [ }, }, }, - "strapi::cors", + { + name: "strapi::cors", + config: { + // Strapi's own default (`origin: '*'`) reflects *any* request Origin + // verbatim while still sending `Access-Control-Allow-Credentials: + // true` — effectively "allow every website, with cookies". The + // better-auth extension's custom content-api routes (organizations/ + // users/packages/templates — see `extensions/better-auth`) are + // deliberately cookie-authenticated (`is-authenticated` policy) with + // no CSRF token of their own, so an unrestricted origin here would + // let any external site ride a logged-in visitor's session to read + // or modify their account data. Restrict to the web app's own origin + // instead. + origin: [env("WEBSITE_URL")], + }, + }, "strapi::poweredBy", "strapi::query", "strapi::body", diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index 47fbb3d2..86b909b0 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -43,6 +43,17 @@ export default ({ env }) => ({ defaultFieldValues: { labels: { official: false, featured: false, paid: false }, }, + submitFields: [ + "name", + "slug", + "description", + "git_repository", + "package_location", + "type", + "readme", + "readme_auto_sync", + "icon", + ], checks: [ "repo_public", "readme_exists", @@ -66,6 +77,16 @@ export default ({ env }) => ({ defaultFieldValues: { labels: { official: false, featured: false, paid: false }, }, + submitFields: [ + "name", + "slug", + "description", + "git_repository", + "preview_link", + "readme", + "readme_auto_sync", + "preview_image", + ], checks: ["repo_public", "readme_exists", "mit_license"], webhooks: { submissionReceived: `${N8N_WEBHOOK_NS}/template-submission-received`, diff --git a/apps/cms/package.json b/apps/cms/package.json index f1fafe36..d6856c8f 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -17,7 +17,7 @@ "upgrade:dry": "npx @strapi/upgrade latest --dry" }, "dependencies": { - "@better-auth-ui/react": "^1.6.3", + "@better-auth-ui/react": "^1.6.44", "@better-auth/infra": "^0.2.5", "@piksail/strapi-provider-email-mailpit": "^1.0.0", "@react-email/render": "^1.4.0", @@ -31,13 +31,17 @@ "@strapi/strapi": "catalog:strapi", "@strapi/types": "catalog:strapi", "@strapi/typescript-utils": "catalog:strapi", + "@strapi/utils": "catalog:strapi", "airtable": "^0.12.2", "better-auth": "catalog:better-auth", "better-sqlite3": "12.4.1", + "isomorphic-dompurify": "^4.2.0", + "lodash": "^4.17.21", "passport-google-oauth2": "^0.2.0", "pg": "^8.21.0", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-email": "^6.6.8", "react-query": "^3.39.3", "react-router-dom": "^6.0.0", "strapi-lucide-icons": "^1.14.0", @@ -50,6 +54,7 @@ "devDependencies": { "@biomejs/biome": "^2.3.11", "@repo/biome-config": "workspace:*", + "@types/lodash": "^4.17.13", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/apps/cms/src/api/package/content-types/package/schema.json b/apps/cms/src/api/package/content-types/package/schema.json index 03c94740..8ac78e6d 100644 --- a/apps/cms/src/api/package/content-types/package/schema.json +++ b/apps/cms/src/api/package/content-types/package/schema.json @@ -24,11 +24,13 @@ }, "name": { "type": "string", - "required": true + "required": true, + "maxLength": 50 }, "description": { "type": "text", - "required": true + "required": true, + "maxLength": 300 }, "icon": { "type": "media", @@ -37,10 +39,12 @@ "allowedTypes": ["images", "files", "videos", "audios"] }, "git_repository": { - "type": "string" + "type": "string", + "required": true }, "package_location": { - "type": "string" + "type": "string", + "required": true }, "type": { "type": "enumeration", @@ -125,7 +129,8 @@ ] }, "submitter_ip": { - "type": "string" + "type": "string", + "private": true }, "submitter_agreed_to_terms": { "type": "boolean", @@ -133,6 +138,10 @@ }, "submission_notes": { "type": "text" + }, + "readme_auto_sync": { + "type": "boolean", + "default": true } } } diff --git a/apps/cms/src/api/package/controllers/package.ts b/apps/cms/src/api/package/controllers/package.ts index 7ef73ffa..7018a467 100644 --- a/apps/cms/src/api/package/controllers/package.ts +++ b/apps/cms/src/api/package/controllers/package.ts @@ -4,4 +4,84 @@ import { factories } from "@strapi/strapi"; -export default factories.createCoreController("api::package.package"); +const READ_ONLY_FIELDS = ["git_repository", "package_location"] as const; + +const EDITABLE_FIELDS = [ + "name", + "description", + // "icon" — deliberately not editable here: a media field written by id + // would let the caller point it at *any* file in the media library, which + // the icon upload route would then delete as the "previous" icon. Icons + // only change through `POST /packages/:documentId/icon`. + "categories", + // "maintainers" — temporarily not editable, pending an invite flow so the + // added user consents first (see the commented-out `MaintainerPicker` in + // apps/web/.../submission-edit-form.tsx). Re-add once that's built. + "readme", + "readme_auto_sync", +] as const; + +export default factories.createCoreController("api::package.package", () => ({ + /** + * PUT /api/packages/:id — reused by the web app's "Submissions" edit + * form. The route's `is-content-owner` policy already restricts who + * can reach this action at all (see `routes/package.ts`); this + * override adds three more layers on top of that: + * + * - blocks the edit entirely unless the submission is already + * `approved` — while it's still `submitted`/`under_review`/ + * `changes_requested`/`rejected`, moderation is reviewing (or has + * reviewed) the content as originally submitted, so the owner can't + * change it out from under that review. Entries predating the + * moderation workflow have no `overall_status` at all (`null`), so + * those are left editable rather than permanently locked out; + * - rejects the request outright if `git_repository`/`package_location` + * are present and differ from the current value — the edit form + * shows these as read-only ("we do not support updating this atm"), + * this is the server-side backstop for anyone calling the API + * directly; + * - whitelists everything else down to just the fields the edit form + * actually exposes, so nothing else can be smuggled into this + * content-api-facing write path. + */ + async update(ctx) { + const entry = ctx.state.contentEntry as Record; + const documentId = ctx.params.id as string; + const incoming = (ctx.request.body?.data ?? {}) as Record; + + if (entry.overall_status && entry.overall_status !== "approved") { + return ctx.badRequest( + "This submission cannot be edited until it has been approved.", + ); + } + + for (const field of READ_ONLY_FIELDS) { + if (field in incoming && incoming[field] !== entry[field]) { + return ctx.badRequest(`${field} cannot be changed.`); + } + } + + ctx.request.body.data = Object.fromEntries( + Object.entries(incoming).filter(([key]) => + (EDITABLE_FIELDS as readonly string[]).includes(key), + ), + ); + + const result = await super.update(ctx); + + // Per product decision, an owner's edit applies immediately — no + // re-entering moderation review. If this submission was already + // approved and published, republish right away so the live version + // picks up the change. A submission still awaiting its first + // approval has no published version yet; leave it as a draft so + // moderation still gates when it first goes live. + const published = await strapi + .documents("api::package.package") + .findOne({ documentId, status: "published", fields: ["documentId"] }); + if (published) { + await strapi.documents("api::package.package").publish({ documentId }); + } + + return result; + }, +})); diff --git a/apps/cms/src/api/package/routes/package.ts b/apps/cms/src/api/package/routes/package.ts index f474bd4d..692cc63c 100644 --- a/apps/cms/src/api/package/routes/package.ts +++ b/apps/cms/src/api/package/routes/package.ts @@ -4,4 +4,21 @@ import { factories } from "@strapi/strapi"; -export default factories.createCoreRouter("api::package.package"); +export default factories.createCoreRouter("api::package.package", { + config: { + update: { + // Reused by the web app's "Submissions" edit form, called directly + // from the browser with a better-auth session cookie rather than a + // Strapi API token — same reasoning as `organization.updateProfile` + // (apps/cms/src/extensions/better-auth/routes/organization.ts). + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + { + name: "plugin::better-auth.is-content-owner", + config: { uid: "api::package.package" }, + }, + ], + }, + }, +}); diff --git a/apps/cms/src/api/profile/content-types/profile/schema.json b/apps/cms/src/api/profile/content-types/profile/schema.json index f14433ac..717d1f4d 100644 --- a/apps/cms/src/api/profile/content-types/profile/schema.json +++ b/apps/cms/src/api/profile/content-types/profile/schema.json @@ -19,10 +19,12 @@ }, "attributes": { "bio": { - "type": "text" + "type": "text", + "maxLength": 300 }, "subtitle": { - "type": "string" + "type": "string", + "maxLength": 50 }, "website": { "type": "string" diff --git a/apps/cms/src/api/template/content-types/template/schema.json b/apps/cms/src/api/template/content-types/template/schema.json index dcaffe57..72d539c3 100644 --- a/apps/cms/src/api/template/content-types/template/schema.json +++ b/apps/cms/src/api/template/content-types/template/schema.json @@ -23,10 +23,12 @@ }, "name": { "type": "string", - "required": true + "required": true, + "maxLength": 50 }, "description": { - "type": "text" + "type": "text", + "maxLength": 300 }, "git_repository": { "type": "string" @@ -112,7 +114,8 @@ ] }, "submitter_ip": { - "type": "string" + "type": "string", + "private": true }, "submitter_agreed_to_terms": { "type": "boolean", @@ -120,6 +123,10 @@ }, "submission_notes": { "type": "text" + }, + "readme_auto_sync": { + "type": "boolean", + "default": true } } } diff --git a/apps/cms/src/api/template/controllers/template.ts b/apps/cms/src/api/template/controllers/template.ts index bc05ceb8..f07ec0da 100644 --- a/apps/cms/src/api/template/controllers/template.ts +++ b/apps/cms/src/api/template/controllers/template.ts @@ -4,4 +4,68 @@ import { factories } from "@strapi/strapi"; -export default factories.createCoreController("api::template.template"); +const READ_ONLY_FIELDS = ["git_repository"] as const; + +const EDITABLE_FIELDS = [ + "name", + "description", + // "preview_image" — deliberately not editable here; see the same note on + // `icon` in the package controller. Only changes through + // `POST /templates/:documentId/preview-image`. + "preview_link", + "categories", + // "maintainers" — temporarily not editable, pending an invite flow so the + // added user consents first (see the commented-out `MaintainerPicker` in + // apps/web/.../submission-edit-form.tsx). Re-add once that's built. + "readme", + "readme_auto_sync", +] as const; + +export default factories.createCoreController("api::template.template", () => ({ + /** + * PUT /api/templates/:id — reused by the web app's "Submissions" edit + * form. See `api::package.package`'s controller override for the full + * reasoning, including the approved-only edit gate (entries predating + * the moderation workflow have no `overall_status` and are left + * editable rather than permanently locked out); templates have no + * `package_location` field, so only `git_repository` is read-only here. + */ + async update(ctx) { + const entry = ctx.state.contentEntry as Record; + const documentId = ctx.params.id as string; + const incoming = (ctx.request.body?.data ?? {}) as Record; + + if (entry.overall_status && entry.overall_status !== "approved") { + return ctx.badRequest( + "This submission cannot be edited until it has been approved.", + ); + } + + for (const field of READ_ONLY_FIELDS) { + if (field in incoming && incoming[field] !== entry[field]) { + return ctx.badRequest(`${field} cannot be changed.`); + } + } + + ctx.request.body.data = Object.fromEntries( + Object.entries(incoming).filter(([key]) => + (EDITABLE_FIELDS as readonly string[]).includes(key), + ), + ); + + const result = await super.update(ctx); + + // See the same auto-republish reasoning on the package controller's + // `update` override — apply the edit live immediately if this + // template was already approved/published, otherwise leave it as a + // draft awaiting its first moderation approval. + const published = await strapi + .documents("api::template.template") + .findOne({ documentId, status: "published", fields: ["documentId"] }); + if (published) { + await strapi.documents("api::template.template").publish({ documentId }); + } + + return result; + }, +})); diff --git a/apps/cms/src/api/template/routes/template.ts b/apps/cms/src/api/template/routes/template.ts index 7392ea1b..d28c3cca 100644 --- a/apps/cms/src/api/template/routes/template.ts +++ b/apps/cms/src/api/template/routes/template.ts @@ -4,4 +4,19 @@ import { factories } from "@strapi/strapi"; -export default factories.createCoreRouter("api::template.template"); +export default factories.createCoreRouter("api::template.template", { + config: { + update: { + // Reused by the web app's "Submissions" edit form — see the same + // config on `api::package.package`'s router for the full reasoning. + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + { + name: "plugin::better-auth.is-content-owner", + config: { uid: "api::template.template" }, + }, + ], + }, + }, +}); diff --git a/apps/cms/src/extensions/better-auth/content-types/account/schema.json b/apps/cms/src/extensions/better-auth/content-types/account/schema.json index ae6f44bb..4dffd16c 100644 --- a/apps/cms/src/extensions/better-auth/content-types/account/schema.json +++ b/apps/cms/src/extensions/better-auth/content-types/account/schema.json @@ -20,7 +20,7 @@ }, "attributes": { "accountId": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { @@ -30,7 +30,7 @@ "required": true }, "providerId": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { @@ -50,7 +50,7 @@ "required": true }, "accessToken": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { @@ -59,7 +59,7 @@ } }, "refreshToken": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { @@ -68,7 +68,7 @@ } }, "idToken": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { @@ -95,7 +95,7 @@ } }, "scope": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { @@ -104,7 +104,7 @@ } }, "password": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { diff --git a/apps/cms/src/extensions/better-auth/content-types/organization/schema.json b/apps/cms/src/extensions/better-auth/content-types/organization/schema.json index 3923e1c2..441b981c 100644 --- a/apps/cms/src/extensions/better-auth/content-types/organization/schema.json +++ b/apps/cms/src/extensions/better-auth/content-types/organization/schema.json @@ -22,7 +22,7 @@ }, "attributes": { "name": { - "type": "text", + "type": "string", "configurable": false, "pluginOptions": { "better-auth": { diff --git a/apps/cms/src/extensions/better-auth/content-types/user/schema.json b/apps/cms/src/extensions/better-auth/content-types/user/schema.json index 870cee92..a8542cf4 100644 --- a/apps/cms/src/extensions/better-auth/content-types/user/schema.json +++ b/apps/cms/src/extensions/better-auth/content-types/user/schema.json @@ -23,7 +23,7 @@ }, "attributes": { "name": { - "type": "text", + "type": "string", "configurable": false, "pluginOptions": { "better-auth": { @@ -35,6 +35,7 @@ "email": { "type": "email", "configurable": false, + "private": true, "pluginOptions": { "better-auth": { "managed": true @@ -81,6 +82,16 @@ "community_star": { "type": "boolean" }, + "slug": { + "type": "text", + "configurable": false, + "pluginOptions": { + "better-auth": { + "managed": true + } + }, + "unique": true + }, "url_alias": { "type": "relation", "relation": "oneToMany", diff --git a/apps/cms/src/extensions/better-auth/content-types/verification/schema.json b/apps/cms/src/extensions/better-auth/content-types/verification/schema.json index ada0859d..0c164f0f 100644 --- a/apps/cms/src/extensions/better-auth/content-types/verification/schema.json +++ b/apps/cms/src/extensions/better-auth/content-types/verification/schema.json @@ -20,7 +20,7 @@ }, "attributes": { "identifier": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { @@ -30,7 +30,7 @@ "required": true }, "value": { - "type": "string", + "type": "text", "configurable": false, "pluginOptions": { "better-auth": { diff --git a/apps/cms/src/extensions/better-auth/controllers/organization.ts b/apps/cms/src/extensions/better-auth/controllers/organization.ts index 8ecefd0e..155ff6df 100644 --- a/apps/cms/src/extensions/better-auth/controllers/organization.ts +++ b/apps/cms/src/extensions/better-auth/controllers/organization.ts @@ -3,7 +3,14 @@ */ import { factories } from "@strapi/strapi"; -import { getPluginService } from "../utils"; +import { fromNodeHeaders } from "better-auth/node"; +import { + getPluginService, + organizationProfileFields, + validateProfileData, +} from "../utils"; + +const MAX_LOGO_SIZE = 2 * 1024 * 1024; export default factories.createCoreController( "plugin::better-auth.organization", @@ -26,8 +33,39 @@ export default factories.createCoreController( }), ]); - ctx.body = { packages, templates }; + // Document-service results — strip `private` attributes (e.g. + // `submitter_ip`, a populated owner's `email`) the core controllers' + // `sanitizeOutput` would otherwise have removed. + ctx.body = { + packages: await strapi.contentAPI.sanitize.output( + packages, + strapi.getModel("api::package.package"), + ), + templates: await strapi.contentAPI.sanitize.output( + templates, + strapi.getModel("api::template.template"), + ), + }; + }, + /** + * GET /organizations/mine — organizations the caller is an + * owner/admin of. Used by the plugin/template submission forms' + * "Submit as" selector, scoped to the caller (via their better-auth + * session), not a specific org — so there's no `is-organization-*` + * policy to apply here, only `is-authenticated`. + */ + async mine(ctx) { + const session = ctx.state.betterAuthSession; + + const organizations = await getPluginService( + "organization", + ).getManageable({ + userId: session.user.id, + }); + + ctx.body = organizations; }, + async members(ctx) { const { id } = ctx.params; @@ -38,7 +76,88 @@ export default factories.createCoreController( query: ctx.query, }); - ctx.body = members; + // Strips private user fields (`email`) — see `relatedContent`. + ctx.body = await strapi.contentAPI.sanitize.output( + members, + strapi.getModel("plugin::better-auth.user"), + ); + }, + + /** + * PUT /organizations/:id/profile — used by the web app's organization + * "Profile" tab. Gated by the `is-authenticated` and + * `is-organization-owner` policies. + */ + async updateProfile(ctx) { + const { id } = ctx.params; + const body = ctx.request.body ?? {}; + + const data = Object.fromEntries( + organizationProfileFields + .filter((field) => field in body) + .map((field) => [field, body[field]]), + ); + + const errors = validateProfileData(data); + if (errors.length > 0) return ctx.badRequest(errors.join(" ")); + + const service = getPluginService("organization"); + + const profile = await service.updateProfile({ + organizationId: id, + data, + }); + + ctx.body = profile; + }, + + /** + * POST /organizations/logo — used by better-auth-ui's + * `organization.logo.upload` config. Gated by the `is-authenticated` + * policy; not organization-scoped, since better-auth-ui doesn't tell + * us which org the logo is for until the follow-up + * `organization.update` call (which better-auth itself already + * restricts to owners/admins of that org). + */ + async uploadLogo(ctx) { + const session = ctx.state.betterAuthSession; + + const uploaded = ctx.request.files?.file; + const file = Array.isArray(uploaded) ? uploaded[0] : uploaded; + if (!file) return ctx.badRequest("No file provided."); + if (!file.mimetype?.startsWith("image/")) { + return ctx.badRequest("Logo must be an image."); + } + if (file.size > MAX_LOGO_SIZE) { + return ctx.badRequest("Logo must be smaller than 2 MB."); + } + + const uploadedFile = await getPluginService("organization").uploadLogo({ + userId: session.user.id, + file, + }); + + ctx.body = { url: uploadedFile.url }; + }, + + /** + * DELETE /organizations/logo — used by better-auth-ui's + * `organization.logo.delete` config. + */ + async deleteLogo(ctx) { + const session = ctx.state.betterAuthSession; + + const { url } = ctx.request.body ?? {}; + if (!url) return ctx.badRequest("Missing url."); + + const deleted = await getPluginService("organization").deleteLogo({ + userId: session.user.id, + url, + headers: fromNodeHeaders(ctx.request.headers), + }); + if (!deleted) return ctx.notFound(); + + ctx.body = { success: true }; }, }), ); diff --git a/apps/cms/src/extensions/better-auth/controllers/submissions.ts b/apps/cms/src/extensions/better-auth/controllers/submissions.ts new file mode 100644 index 00000000..7bd91baa --- /dev/null +++ b/apps/cms/src/extensions/better-auth/controllers/submissions.ts @@ -0,0 +1,429 @@ +/** + * submissions controller — backs the web app's "Submissions" settings tab. + */ + +import type { UID } from "@strapi/strapi"; +import { fromNodeHeaders } from "better-auth/node"; +import { + findContentOwner, + getPluginService, + hasOrganizationUpdatePermission, + isOrganizationMember, + type OwnerType, + sanitizeIfSvg, +} from "../utils"; + +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; + +type ContentEntry = Record & { + id: number; + documentId: string; +}; + +type PopulatedEntry = ContentEntry & { + maintainers?: { id: number }[] | null; +}; + +/** + * Strips `private` attributes (e.g. `submitter_ip`) and passwords from + * document-service results before they leave the content API — these + * custom actions bypass the core controllers' `sanitizeOutput`. + */ +function sanitizeEntries(data: T, uid: UID.ContentType): Promise { + return strapi.contentAPI.sanitize.output( + data, + strapi.getModel(uid), + ) as Promise; +} + +/** + * Shared by `getSubmission` and `canEdit`: is the caller the owner of an + * entry with this `owner`? User-owned → the account itself; org-owned → + * any owner/admin of that org, via `hasOrganizationUpdatePermission` + * (same call `is-content-owner`/`is-organization-owner` use — including + * its defensive handling of a caller who has no membership in the org at + * all, e.g. a maintainer of org-owned content who isn't themselves a + * member of that org: a real case, not an error). Deliberately excludes + * maintainers — only the owner may edit, per the confirmed product + * decision; maintainers get read visibility elsewhere, never this. + */ +async function resolveIsOwner( + ctx, + owner: { id: number; type: OwnerType } | null, +): Promise { + if (!owner) return false; + const session = ctx.state.betterAuthSession; + + if (owner.type === "plugin::better-auth.user") { + return String(owner.id) === String(session.user.id); + } + + return hasOrganizationUpdatePermission( + fromNodeHeaders(ctx.request.headers), + String(owner.id), + ); +} + +/** + * Shared by `getPackage`/`getTemplate`: the single-item read behind the + * edit form. Unlike every write action here, this is deliberately visible + * to more than just the owner (read-only): a maintainer, so they can see + * the submission's current state in their own `/account/submissions`, + * and — for org-owned content — any member of the owning org, so clicking + * "View" from the org's "Submissions" list works for every member, not + * just an owner/admin. Only the owner can actually save changes + * (enforced separately by `is-content-owner` on the write routes). + */ +async function getSubmission( + ctx, + uid: UID.ContentType, + populate: Record, +) { + const session = ctx.state.betterAuthSession; + const { documentId } = ctx.params; + + const entry = (await strapi.documents(uid).findOne({ + documentId, + status: "draft", + populate, + })) as PopulatedEntry | null; + if (!entry) return ctx.notFound(); + + const owner = await findContentOwner(uid, documentId); + if (!owner) return ctx.forbidden(); + + const isOwner = await resolveIsOwner(ctx, owner); + + const isMaintainer = (entry.maintainers ?? []).some( + (maintainer) => String(maintainer.id) === String(session.user.id), + ); + + const isFellowOrgMember = + owner.type === "plugin::better-auth.organization" && + (await isOrganizationMember(session.user.id, owner.id)); + + if (!isOwner && !isMaintainer && !isFellowOrgMember) { + return ctx.forbidden(); + } + + // Link to the live public page, if there is one — a submission still + // awaiting its first moderation approval has no published version (and + // no page) yet. `url_alias` is set by strapi-plugin-webtools and is the + // only reliable source for this path (see every other package/template + // link in the web app — none of them hand-build the URL). + // `url_alias` only type-checks against a literal draftAndPublish uid — + // `uid` here is the broader `UID.ContentType` union, but at runtime + // it's always one of the two content types below, both of which have it. + const published = (await strapi + .documents(uid as "api::package.package" | "api::template.template") + .findOne({ + documentId, + status: "published", + fields: ["documentId"], + populate: { url_alias: { fields: ["url_path"] } }, + })) as { url_alias?: { url_path?: string | null }[] | null } | null; + const url = published?.url_alias?.[0]?.url_path ?? null; + + ctx.body = { ...(await sanitizeEntries(entry, uid)), isOwner, url }; +} + +/** + * Shared by `canEditPackage`/`canEditTemplate` — backs the "Edit" button + * shown on the public package/template page. Deliberately much cheaper + * than `getSubmission`: no relations/readme populated, just the owner + * check, since this is called on every page load for a logged-in viewer + * (the web app only calls it when it already knows a session exists — see + * `apps/web/src/features/submissions/lib/can-edit.ts` — but this action + * itself still requires `is-authenticated`, so an anonymous request never + * reaches it at all). Answers `{ canEdit: false }` for a logged-in + * non-owner (maintainers included — only the owner may edit) rather than + * 403ing: anyone signed in may ask, the answer is what's gated. + * + * Also reports which settings context the edit link should point at — + * `/account/submissions/...` for a user-owned entry, or + * `/org//submissions/...` for an org-owned one, since the edit + * form lives inside whichever settings shell the caller reaches it + * through (see account-view.tsx / organization-view.tsx). + */ +async function canEdit(ctx, uid: UID.ContentType) { + const { documentId } = ctx.params; + const owner = await findContentOwner(uid, documentId); + const isOwner = await resolveIsOwner(ctx, owner); + + let ownerSlug: string | null = null; + if (isOwner && owner?.type === "plugin::better-auth.organization") { + const [organization] = await strapi + .documents("plugin::better-auth.organization") + .findMany({ + filters: { id: owner.id }, + fields: ["slug"], + pagination: { pageSize: 1 }, + }); + ownerSlug = organization?.slug ?? null; + } + + ctx.body = { canEdit: isOwner, ownerType: owner?.type ?? null, ownerSlug }; +} + +/** + * Shared by `uploadPackageIcon`/`uploadTemplatePreviewImage`: uploads a + * replacement image, points `field` at it, deletes whatever it replaced, + * and republishes immediately if the entry was already live — same + * reasoning as the package/template controllers' `update` override. + */ +async function uploadImage( + ctx, + uid: UID.ContentType, + field: "icon" | "preview_image", +) { + const entry = ctx.state.contentEntry as ContentEntry; + + // Same approved-only gate as the package/template `update` overrides. + if (entry.overall_status && entry.overall_status !== "approved") { + return ctx.badRequest( + "This submission cannot be edited until it has been approved.", + ); + } + + const uploaded = ctx.request.files?.file; + const file = Array.isArray(uploaded) ? uploaded[0] : uploaded; + if (!file) return ctx.badRequest("No file provided."); + if (!file.mimetype?.startsWith("image/")) { + return ctx.badRequest("Image must be an image file."); + } + if (file.size > MAX_IMAGE_SIZE) { + return ctx.badRequest("Image must be smaller than 5 MB."); + } + + await sanitizeIfSvg(file); + + const previous = await strapi.documents(uid).findOne({ + documentId: entry.documentId, + status: "draft", + populate: { [field]: { fields: ["id"] } }, + }); + const previousFile = ( + previous as unknown as Record + )?.[field]; + + const [uploadedFile] = await strapi + .plugin("upload") + .service("upload") + .upload({ + data: { fileInfo: { name: `${field}-${entry.documentId}` } }, + files: file, + }); + + await strapi.documents(uid).update({ + status: "draft", + documentId: entry.documentId, + data: { [field]: uploadedFile.id }, + }); + + // Only delete the replaced file if nothing but this entry (its draft + // and/or published row) still uses it — never a file shared with, or + // belonging to, anything else in the media library. + if (previousFile?.id) { + const old = await strapi.db.query("plugin::upload.file").findOne({ + where: { id: previousFile.id }, + populate: ["related"], + }); + const related = + (old?.related as { documentId?: string; __type: string }[]) ?? []; + const onlyUsedByThisEntry = related.every( + (item) => item.__type === uid && item.documentId === entry.documentId, + ); + if (old && onlyUsedByThisEntry) { + await strapi.plugin("upload").service("upload").remove(old); + } + } + + const published = await strapi.documents(uid).findOne({ + documentId: entry.documentId, + status: "published", + fields: ["documentId"], + }); + if (published) { + // `.publish()` only type-checks against a literal draftAndPublish uid — + // `uid` here is the broader `UID.ContentType` union, but at runtime + // it's always one of the two content types below. + await strapi + .documents(uid as "api::package.package" | "api::template.template") + .publish({ documentId: entry.documentId }); + } + + ctx.body = { url: uploadedFile.url }; +} + +/** Shared by `transferPackage`/`transferTemplate`. */ +async function transferContent(ctx, uid: UID.ContentType) { + const { documentId } = ctx.params; + const { ownerDocumentId, ownerType } = (ctx.request.body ?? {}) as { + ownerDocumentId?: string; + ownerType?: string; + }; + + if (!ownerDocumentId || !ownerType) { + return ctx.badRequest("ownerDocumentId and ownerType are required."); + } + if ( + ownerType !== "plugin::better-auth.user" && + ownerType !== "plugin::better-auth.organization" + ) { + return ctx.badRequest("Invalid ownerType."); + } + + try { + const owner = await getPluginService("submissions").transferOwnership({ + uid, + documentId, + ownerDocumentId, + ownerType, + }); + ctx.body = { success: true, owner }; + } catch (err) { + return ctx.badRequest((err as Error).message); + } +} + +export default () => ({ + /** + * GET /users/me/submissions — the web app's personal "Submissions" tab. + * Gated by `is-authenticated` only; ownership isn't checked per-row here + * since the listing is inherently scoped to the caller's own id. + */ + async mine(ctx) { + const session = ctx.state.betterAuthSession; + const { packages, templates } = await getPluginService( + "submissions", + ).getUserSubmissions(session.user.id); + ctx.body = { + packages: await sanitizeEntries(packages, "api::package.package"), + templates: await sanitizeEntries(templates, "api::template.template"), + }; + }, + + /** + * GET /organizations/:id/submissions — the web app's organization + * "Submissions" tab. Gated by `is-authenticated` + `is-organization-member` + * — any member can see the list, but only an owner/admin can edit, so + * `isOwner` is computed once for the caller here and applied uniformly + * to every row (org-level, unlike the personal list where it varies + * per-item by who maintains what). + */ + async organizationMine(ctx) { + const { id } = ctx.params; + + const isOwner = await hasOrganizationUpdatePermission( + fromNodeHeaders(ctx.request.headers), + id, + ); + const result = + await getPluginService("submissions").getOrganizationSubmissions(id); + const [packages, templates] = await Promise.all([ + sanitizeEntries(result.packages, "api::package.package"), + sanitizeEntries(result.templates, "api::template.template"), + ]); + + ctx.body = { + packages: packages.map((entry) => ({ ...entry, isOwner })), + templates: templates.map((entry) => ({ ...entry, isOwner })), + }; + }, + + /** + * GET /user-by-slug?slug= — backs the maintainer picker and the + * transfer "new owner" picker on the edit form. An exact, single-result + * lookup (not a search-as-you-type) — the slug is already public (a + * user's profile URL), so this reveals nothing new, unlike a + * name/email search would. Deliberately not nested under `/users/` — + * that prefix is already claimed by the public `GET /users/:id` route, + * and a sibling static path there would depend on fragile + * route-registration ordering to avoid `:id` swallowing it first. + */ + async findUserBySlug(ctx) { + const slug = typeof ctx.query.slug === "string" ? ctx.query.slug : ""; + const user = await getPluginService("submissions").findUserBySlug(slug); + ctx.body = { data: user }; + }, + + /** + * GET /organization-by-slug?slug= — backs the transfer "new owner" + * picker when transferring to an organization. Same reasoning as + * `findUserBySlug` above. + */ + async findOrganizationBySlug(ctx) { + const slug = typeof ctx.query.slug === "string" ? ctx.query.slug : ""; + const organization = + await getPluginService("submissions").findOrganizationBySlug(slug); + ctx.body = { data: organization }; + }, + + /** GET /packages/:documentId/submission — the edit form's data source. */ + async getPackage(ctx) { + return getSubmission(ctx, "api::package.package", { + categories: true, + maintainers: { fields: ["documentId", "name", "image"] }, + icon: true, + }); + }, + + /** GET /templates/:documentId/submission — the edit form's data source. */ + async getTemplate(ctx) { + return getSubmission(ctx, "api::template.template", { + categories: true, + maintainers: { fields: ["documentId", "name", "image"] }, + preview_image: true, + }); + }, + + /** GET /packages/:documentId/can-edit — backs the public page's "Edit" button. */ + async canEditPackage(ctx) { + return canEdit(ctx, "api::package.package"); + }, + + /** GET /templates/:documentId/can-edit — backs the public page's "Edit" button. */ + async canEditTemplate(ctx) { + return canEdit(ctx, "api::template.template"); + }, + + /** POST /packages/:documentId/icon */ + async uploadPackageIcon(ctx) { + return uploadImage(ctx, "api::package.package", "icon"); + }, + + /** POST /templates/:documentId/preview-image */ + async uploadTemplatePreviewImage(ctx) { + return uploadImage(ctx, "api::template.template", "preview_image"); + }, + + /** POST /packages/:documentId/transfer */ + async transferPackage(ctx) { + return transferContent(ctx, "api::package.package"); + }, + + /** POST /templates/:documentId/transfer */ + async transferTemplate(ctx) { + return transferContent(ctx, "api::template.template"); + }, + + /** DELETE /packages/:documentId/submission */ + async deletePackage(ctx) { + const { documentId } = ctx.params; + await getPluginService("submissions").deleteSubmission( + "api::package.package", + documentId, + ); + ctx.body = { success: true }; + }, + + /** DELETE /templates/:documentId/submission */ + async deleteTemplate(ctx) { + const { documentId } = ctx.params; + await getPluginService("submissions").deleteSubmission( + "api::template.template", + documentId, + ); + ctx.body = { success: true }; + }, +}); diff --git a/apps/cms/src/extensions/better-auth/controllers/user.ts b/apps/cms/src/extensions/better-auth/controllers/user.ts index b33da9ae..37b40b04 100644 --- a/apps/cms/src/extensions/better-auth/controllers/user.ts +++ b/apps/cms/src/extensions/better-auth/controllers/user.ts @@ -3,7 +3,14 @@ */ import { factories } from "@strapi/strapi"; -import { getPluginService } from "../utils"; +import { fromNodeHeaders } from "better-auth/node"; +import { + getPluginService, + userProfileFields, + validateProfileData, +} from "../utils"; + +const MAX_AVATAR_SIZE = 2 * 1024 * 1024; export default factories.createCoreController( "plugin::better-auth.user", @@ -26,7 +33,105 @@ export default factories.createCoreController( }), ]); - ctx.body = { packages, templates }; + // Document-service results — strip `private` attributes (e.g. + // `submitter_ip`, a populated owner's `email`) the core controllers' + // `sanitizeOutput` would otherwise have removed. + ctx.body = { + packages: await strapi.contentAPI.sanitize.output( + packages, + strapi.getModel("api::package.package"), + ), + templates: await strapi.contentAPI.sanitize.output( + templates, + strapi.getModel("api::template.template"), + ), + }; + }, + + /** + * GET /users/me/profile — used by the web app's personal "Profile" + * tab. Gated by the `is-authenticated` policy. + */ + async getProfile(ctx) { + const session = ctx.state.betterAuthSession; + + const profile = await getPluginService("user").getProfile({ + userId: session.user.id, + }); + + ctx.body = profile ?? {}; + }, + + /** + * PUT /users/me/profile — used by the web app's personal "Profile" + * tab. Gated by the `is-authenticated` policy. + */ + async updateProfile(ctx) { + const session = ctx.state.betterAuthSession; + const body = ctx.request.body ?? {}; + + const data = Object.fromEntries( + userProfileFields + .filter((field) => field in body) + .map((field) => [field, body[field]]), + ); + + const errors = validateProfileData(data); + if (errors.length > 0) return ctx.badRequest(errors.join(" ")); + + const profile = await getPluginService("user").updateProfile({ + userId: session.user.id, + data, + }); + + ctx.body = profile; + }, + + /** + * POST /users/me/avatar — used by better-auth-ui's `avatar.upload` + * config. Gated by the `is-authenticated` policy, which populates + * `ctx.state.betterAuthSession`. + */ + async uploadAvatar(ctx) { + const session = ctx.state.betterAuthSession; + + const uploaded = ctx.request.files?.file; + const file = Array.isArray(uploaded) ? uploaded[0] : uploaded; + if (!file) return ctx.badRequest("No file provided."); + if (!file.mimetype?.startsWith("image/")) { + return ctx.badRequest("Avatar must be an image."); + } + if (file.size > MAX_AVATAR_SIZE) { + return ctx.badRequest("Avatar must be smaller than 2 MB."); + } + + const uploadedFile = await getPluginService("user").uploadAvatar({ + userId: session.user.id, + file, + }); + + ctx.body = { url: uploadedFile.url }; + }, + + /** + * DELETE /users/me/avatar — used by better-auth-ui's `avatar.delete` + * config. Gated by the `is-authenticated` policy, which populates + * `ctx.state.betterAuthSession`. + */ + async deleteAvatar(ctx) { + const session = ctx.state.betterAuthSession; + + const { url } = ctx.request.body ?? {}; + if (!url) return ctx.badRequest("Missing url."); + + const deleted = await getPluginService("user").deleteAvatar({ + userId: session.user.id, + url, + headers: fromNodeHeaders(ctx.request.headers), + }); + if (!deleted) return ctx.notFound(); + + ctx.body = { success: true }; }, }), ); diff --git a/apps/cms/src/extensions/better-auth/policies/is-authenticated.ts b/apps/cms/src/extensions/better-auth/policies/is-authenticated.ts new file mode 100644 index 00000000..105a08bc --- /dev/null +++ b/apps/cms/src/extensions/better-auth/policies/is-authenticated.ts @@ -0,0 +1,92 @@ +/** + * is-authenticated policy + * + * Requires a valid better-auth session (via cookie) for the current + * request. This plugin's content-api routes carry no Strapi API token, so + * routes relying on this policy must also set `config.auth = false` — + * otherwise Strapi's own content-api-token check rejects the request + * before this policy ever runs. + * + * On success, stashes the resolved session on `ctx.state` so downstream + * policies (e.g. is-content-owner) and the controller action don't need + * to fetch it again. + * + * Rejects by *throwing* `errors.UnauthorizedError`, not by calling + * `ctx.unauthorized()`: policies receive a `PolicyContext` (see + * `@strapi/utils`'s `createPolicyContext`), which is `Object.assign({}, + * ctx)` — a *plain object* copied from `ctx`'s own enumerable properties + * only. `ctx.unauthorized`/`.forbidden`/`.badRequest`/`.notFound` are + * Koa convenience methods added to the shared `app.context` prototype, + * not per-request own properties, so they're silently dropped by that + * copy — calling them from a policy throws `"ctx.unauthorized is not a + * function"` (confirmed directly). Throwing the `@strapi/utils` error + * class instead is the correct policy-layer pattern: the outer + * `strapi::errors` middleware catches any thrown `ApplicationError` + * subclass and maps it to the right status (401 for + * `UnauthorizedError`) — see `@strapi/core`'s `services/errors.js`. + * + * Also stands in for Strapi's own auth strategy, which `auth: false` + * skips entirely (`ctx.state.auth` is never set — see + * `@strapi/core`'s `services/auth/index.js`, `authenticate()`: `if + * (config === false) return next();`). Without this, the *content-api's* + * own request validation — `strapi.contentAPI.validate.input`, called by + * every core `update`/`create` action — defaults the missing auth to `{}` + * (`fp.prop('state.auth', ctx) ?? {}`, in `services/content-api/index.js`) + * instead of skipping its relation-permission check, then that check + * (`throwRestrictedRelations`, in `@strapi/utils`) calls + * `auth.strategy.verify`, which throws on the missing `.strategy` — always + * failing closed and rejecting the *first relation field in the request + * body* with `ValidationError: Invalid key `, regardless of its + * shape. (Confirmed directly against this exact payload/error via a + * throwaway script calling `contentAPI.validate.input` with `auth: {}`.) + * A minimal permissive `verify` is correct here, not just a workaround: + * this request has already passed real, fine-grained authorization (this + * policy plus whichever ownership policy runs after it) — Strapi's own + * coarse "can this principal read the target content-type" check is + * redundant on top of that, not a check we're bypassing. + * + * Also rejects a mismatched `Origin` — this plugin's routes carry no CSRF + * token, only this cookie. The cookie is `SameSite=Lax` (see + * `advanced.defaultCookieAttributes` in `lib/auth.ts`), which already stops + * it being attached to most cross-site requests, but a plain auto-submitting + * HTML `
` (`POST`, `enctype="multipart/form-data"`) is a top-level + * navigation that `Lax` still permits — and, unlike a cross-site + * `fetch`/XHR, it reaches a `POST` route like this with no CORS preflight at + * all, since `strapi::cors`'s origin allow-list is only consulted for + * script-initiated requests, never for a form navigation. This Origin check + * closes that remaining gap. Better Auth's own handler + * (everything under `/api/auth/*`) already guards against exactly this by + * verifying `Origin` against `trustedOrigins`; this mirrors that same + * check for this plugin's *own* routes, which sit outside that handler and + * so aren't covered by it. A cross-origin `fetch`/XHR always sets `Origin` + * (this plugin's own client-side callers included — see + * `apps/web/src/features/submissions/lib/submission-edit.ts` and + * similar), so this only ever rejects a request that's either forged or + * genuinely misconfigured — never a normal call from the web app. + */ + +import { errors } from "@strapi/utils"; +import { fromNodeHeaders } from "better-auth/node"; +import { auth } from "../../../lib/auth"; + +/** `Origin` header has no path — normalize away a trailing slash on `WEBSITE_URL`. */ +const TRUSTED_ORIGIN = process.env.WEBSITE_URL?.replace(/\/+$/, ""); + +export default async function isAuthenticated(ctx) { + const origin = ctx.request.headers.origin; + if (origin && origin !== TRUSTED_ORIGIN) throw new errors.UnauthorizedError(); + + const session = await auth.api.getSession({ + headers: fromNodeHeaders(ctx.request.headers), + }); + + if (!session?.user) throw new errors.UnauthorizedError(); + + ctx.state.betterAuthSession = session; + ctx.state.auth = { + strategy: { name: "better-auth-session", verify: async () => {} }, + credentials: session.user, + }; + + return true; +} diff --git a/apps/cms/src/extensions/better-auth/policies/is-content-owner.ts b/apps/cms/src/extensions/better-auth/policies/is-content-owner.ts new file mode 100644 index 00000000..c24f7f74 --- /dev/null +++ b/apps/cms/src/extensions/better-auth/policies/is-content-owner.ts @@ -0,0 +1,73 @@ +/** + * is-content-owner policy + * + * Requires the caller to be the *owner* of the package/template identified + * by `ctx.params.documentId` (or `ctx.params.id`, for routes that reuse the + * core `update` action's param name) — the account itself for a user-owned + * entry, or an owner/admin of the owning organization for an org-owned one + * (delegated to the same `hasPermission` call `is-organization-owner` uses, + * so it stays correct if custom roles/statements are ever configured). + * + * Deliberately excludes maintainers: only the owner may edit, transfer, or + * delete a submission — maintainers get read visibility only. That's + * enforced here, server-side, not just by disabling the form client-side. + * A maintainer of org-owned content who isn't themselves a member of that + * org is a real, expected case here (not an error) — handled via + * `hasOrganizationUpdatePermission`, which resolves to `false` rather + * than throwing for it (see that function's own comment). + * + * Must run after `is-authenticated`. Route config: + * `{ name: "plugin::better-auth.is-content-owner", config: { uid: "api::package.package" } }` + * — the content type has to come from route config since one policy + * serves both `api::package.package` and `api::template.template`. + * + * On success, stashes the resolved entry on `ctx.state.contentEntry` so the + * controller action doesn't need to refetch it. + * + * Rejects by *throwing* the matching `@strapi/utils` error class, not by + * calling `ctx.badRequest()`/`.notFound()`/`.forbidden()` — see + * `is-authenticated.ts`'s comment for why those Koa convenience methods + * don't exist on a policy's `ctx`. + */ + +import type { UID } from "@strapi/strapi"; +import { errors } from "@strapi/utils"; +import { fromNodeHeaders } from "better-auth/node"; +import { hasOrganizationUpdatePermission } from "../utils"; +import { findContentOwner } from "../utils/cascade-delete"; + +export default async function isContentOwner( + ctx, + config: { uid: UID.ContentType }, +) { + const documentId: string | undefined = ctx.params.documentId ?? ctx.params.id; + if (!documentId) throw new errors.ValidationError("Missing content id."); + + const entry = await strapi.documents(config.uid).findOne({ + documentId, + status: "draft", + }); + if (!entry) throw new errors.NotFoundError(); + + ctx.state.contentEntry = entry; + + const owner = await findContentOwner(config.uid, documentId); + if (!owner) throw new errors.ForbiddenError(); + + const session = ctx.state.betterAuthSession; + + if (owner.type === "plugin::better-auth.user") { + if (String(owner.id) === String(session.user.id)) return true; + throw new errors.ForbiddenError(); + } + + if (owner.type === "plugin::better-auth.organization") { + const allowed = await hasOrganizationUpdatePermission( + fromNodeHeaders(ctx.request.headers), + String(owner.id), + ); + if (allowed) return true; + } + + throw new errors.ForbiddenError(); +} diff --git a/apps/cms/src/extensions/better-auth/policies/is-organization-member.ts b/apps/cms/src/extensions/better-auth/policies/is-organization-member.ts new file mode 100644 index 00000000..db01c884 --- /dev/null +++ b/apps/cms/src/extensions/better-auth/policies/is-organization-member.ts @@ -0,0 +1,29 @@ +/** + * is-organization-member policy + * + * Requires the caller to be *any* member of the organization identified by + * `ctx.params.id` — owner, admin, or plain member. For read-only org + * routes (e.g. the "Submissions" list) where every member should see the + * org's content, unlike `is-organization-owner` (owner/admin only, for + * actions that actually change something). + * + * Must run after `is-authenticated`. + * + * Rejects by *throwing* `errors.ForbiddenError`, not by calling + * `ctx.forbidden()` — see `is-authenticated.ts`'s comment for why that + * Koa convenience method doesn't exist on a policy's `ctx`. + */ + +import { errors } from "@strapi/utils"; +import { isOrganizationMember } from "../utils"; + +export default async function isOrganizationMemberPolicy(ctx) { + const { id: organizationId } = ctx.params; + const session = ctx.state.betterAuthSession; + + const allowed = await isOrganizationMember(session.user.id, organizationId); + + if (!allowed) throw new errors.ForbiddenError(); + + return true; +} diff --git a/apps/cms/src/extensions/better-auth/policies/is-organization-owner.ts b/apps/cms/src/extensions/better-auth/policies/is-organization-owner.ts new file mode 100644 index 00000000..394ee901 --- /dev/null +++ b/apps/cms/src/extensions/better-auth/policies/is-organization-owner.ts @@ -0,0 +1,35 @@ +/** + * is-organization-owner policy + * + * Requires the caller to be an owner or admin of the organization + * identified by `ctx.params.id` — i.e. anyone better-auth's organization + * plugin grants `organization: ["update"]` to by default (owner + admin, + * not plain member). Delegates to `hasOrganizationUpdatePermission` + * (which wraps the real `hasPermission` API defensively — see its own + * comment) instead of hand-rolling role checks, so this stays correct if + * custom roles or statements are ever configured in `lib/auth.ts`. + * + * Must run after `is-authenticated` so an unauthenticated request gets a + * 401 from that policy rather than a 403 from this one. + * + * Rejects by *throwing* `errors.ForbiddenError`, not by calling + * `ctx.forbidden()` — see `is-authenticated.ts`'s comment for why that + * Koa convenience method doesn't exist on a policy's `ctx`. + */ + +import { errors } from "@strapi/utils"; +import { fromNodeHeaders } from "better-auth/node"; +import { hasOrganizationUpdatePermission } from "../utils"; + +export default async function isOrganizationOwner(ctx) { + const { id: organizationId } = ctx.params; + + const allowed = await hasOrganizationUpdatePermission( + fromNodeHeaders(ctx.request.headers), + organizationId, + ); + + if (!allowed) throw new errors.ForbiddenError(); + + return true; +} diff --git a/apps/cms/src/extensions/better-auth/routes/organization.ts b/apps/cms/src/extensions/better-auth/routes/organization.ts index 2b138174..72e3da58 100644 --- a/apps/cms/src/extensions/better-auth/routes/organization.ts +++ b/apps/cms/src/extensions/better-auth/routes/organization.ts @@ -11,6 +11,19 @@ export default { prefix: "", }, }, + { + method: "GET", + path: "/organizations/mine", + handler: "organization.mine", + config: { + // Registered before `/organizations/:id` — otherwise that + // route would match `mine` as an `:id` value first. + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, { method: "GET", path: "/organizations/:id", @@ -32,6 +45,46 @@ export default { handler: "organization.members", config: { policies: [], middlewares: [], prefix: "" }, }, + { + method: "PUT", + path: "/organizations/:id/profile", + handler: "organization.updateProfile", + config: { + // Called directly from the browser with a better-auth session + // cookie, not a Strapi API token, so the default + // content-api-token check must be skipped in favor of our own + // session- and role-based policies. + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + "plugin::better-auth.is-organization-owner", + ], + middlewares: [], + prefix: "", + }, + }, + { + method: "POST", + path: "/organizations/logo", + handler: "organization.uploadLogo", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + method: "DELETE", + path: "/organizations/logo", + handler: "organization.deleteLogo", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, ], }, }; diff --git a/apps/cms/src/extensions/better-auth/routes/submissions.ts b/apps/cms/src/extensions/better-auth/routes/submissions.ts new file mode 100644 index 00000000..2824a24b --- /dev/null +++ b/apps/cms/src/extensions/better-auth/routes/submissions.ts @@ -0,0 +1,221 @@ +const PACKAGE_OWNER_POLICY = { + name: "plugin::better-auth.is-content-owner", + config: { uid: "api::package.package" }, +}; + +const TEMPLATE_OWNER_POLICY = { + name: "plugin::better-auth.is-content-owner", + config: { uid: "api::template.template" }, +}; + +export default { + "content-api": { + type: "content-api", + routes: [ + { + method: "GET", + path: "/users/me/submissions", + handler: "submissions.mine", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + // Any member of the org can *see* its submissions (read-only) — + // only an owner/admin can actually edit one, which the controller + // itself tags per-item as `isOwner`, and the write routes below + // enforce separately via `is-content-owner`. + method: "GET", + path: "/organizations/:id/submissions", + handler: "submissions.organizationMine", + config: { + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + "plugin::better-auth.is-organization-member", + ], + middlewares: [], + prefix: "", + }, + }, + { + // Not nested under `/users/` — that prefix already has a public + // `GET /users/:id` route, and this must not depend on route + // registration order to avoid `:id` swallowing "by-slug" first. + method: "GET", + path: "/user-by-slug", + handler: "submissions.findUserBySlug", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + // Same not-nested-under-`/organizations/` reasoning as above. + method: "GET", + path: "/organization-by-slug", + handler: "submissions.findOrganizationBySlug", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + // Read side of the edit form — deliberately gated by + // `is-authenticated` only, not `is-content-owner`: a maintainer + // (not just the owner) can view a submission read-only. The + // controller itself enforces owner-or-maintainer visibility and + // tags the response with `isOwner`. + method: "GET", + path: "/packages/:documentId/submission", + handler: "submissions.getPackage", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + method: "GET", + path: "/templates/:documentId/submission", + handler: "submissions.getTemplate", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + // Backs the "Edit" button on the public package page — gated by + // `is-authenticated` only (not `is-content-owner`): the caller is + // asking "can I edit this?", not attempting to edit it, so it + // must return an answer (`{ canEdit: false }`) rather than 403 for + // a logged-in non-owner. + method: "GET", + path: "/packages/:documentId/can-edit", + handler: "submissions.canEditPackage", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + method: "GET", + path: "/templates/:documentId/can-edit", + handler: "submissions.canEditTemplate", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + method: "POST", + path: "/packages/:documentId/icon", + handler: "submissions.uploadPackageIcon", + config: { + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + PACKAGE_OWNER_POLICY, + ], + middlewares: [], + prefix: "", + }, + }, + { + method: "POST", + path: "/templates/:documentId/preview-image", + handler: "submissions.uploadTemplatePreviewImage", + config: { + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + TEMPLATE_OWNER_POLICY, + ], + middlewares: [], + prefix: "", + }, + }, + // Ownership transfer — temporarily disabled (route removed, not just + // gated) pending a consent flow for the destination owner. As it + // stands, `transferOwnership` (see services/submissions.ts) accepts + // any `ownerDocumentId`/`ownerType` the caller supplies with no check + // that the destination user/org agreed to it, and — since this is a + // `POST` route with no CSRF token, only a cookie-based session — that + // was reachable via a forged cross-site form submission, not just a + // deliberate call. `transferPackage`/`transferTemplate` (controller), + // `transferOwnership` (service), and the `OwnerPicker`/`transferSubmission` + // front-end code are left in place; re-add these two route entries + // once destination consent is implemented. + // { + // method: "POST", + // path: "/packages/:documentId/transfer", + // handler: "submissions.transferPackage", + // config: { + // auth: false, + // policies: [ + // "plugin::better-auth.is-authenticated", + // PACKAGE_OWNER_POLICY, + // ], + // middlewares: [], + // prefix: "", + // }, + // }, + // { + // method: "POST", + // path: "/templates/:documentId/transfer", + // handler: "submissions.transferTemplate", + // config: { + // auth: false, + // policies: [ + // "plugin::better-auth.is-authenticated", + // TEMPLATE_OWNER_POLICY, + // ], + // middlewares: [], + // prefix: "", + // }, + // }, + { + method: "DELETE", + path: "/packages/:documentId/submission", + handler: "submissions.deletePackage", + config: { + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + PACKAGE_OWNER_POLICY, + ], + middlewares: [], + prefix: "", + }, + }, + { + method: "DELETE", + path: "/templates/:documentId/submission", + handler: "submissions.deleteTemplate", + config: { + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + TEMPLATE_OWNER_POLICY, + ], + middlewares: [], + prefix: "", + }, + }, + ], + }, +}; diff --git a/apps/cms/src/extensions/better-auth/routes/user.ts b/apps/cms/src/extensions/better-auth/routes/user.ts index 6c1b567a..08a883d9 100644 --- a/apps/cms/src/extensions/better-auth/routes/user.ts +++ b/apps/cms/src/extensions/better-auth/routes/user.ts @@ -26,6 +26,58 @@ export default { handler: "user.relatedContent", config: { policies: [], middlewares: [], prefix: "" }, }, + { + method: "GET", + path: "/users/me/profile", + handler: "user.getProfile", + config: { + // Called directly from the browser with a better-auth session + // cookie, not a Strapi API token, so the default + // content-api-token check must be skipped in favor of our own + // session-based policy. + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + method: "PUT", + path: "/users/me/profile", + handler: "user.updateProfile", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + method: "POST", + path: "/users/me/avatar", + handler: "user.uploadAvatar", + config: { + // These are called directly from the browser with a better-auth + // session cookie, not a Strapi API token, so the default + // content-api-token check must be skipped in favor of our own + // session-based policy. + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, + { + method: "DELETE", + path: "/users/me/avatar", + handler: "user.deleteAvatar", + config: { + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, ], }, }; diff --git a/apps/cms/src/extensions/better-auth/services/index.ts b/apps/cms/src/extensions/better-auth/services/index.ts index 2795350e..5ce1fa0a 100644 --- a/apps/cms/src/extensions/better-auth/services/index.ts +++ b/apps/cms/src/extensions/better-auth/services/index.ts @@ -1,7 +1,9 @@ import organization from "./organization"; +import submissions from "./submissions"; import user from "./user"; export default { organization, + submissions, user, }; diff --git a/apps/cms/src/extensions/better-auth/services/organization.ts b/apps/cms/src/extensions/better-auth/services/organization.ts index 7ca5be0e..0f4174f0 100644 --- a/apps/cms/src/extensions/better-auth/services/organization.ts +++ b/apps/cms/src/extensions/better-auth/services/organization.ts @@ -3,11 +3,54 @@ */ import { factories, type Modules, type UID } from "@strapi/strapi"; -import { extractContentTypeName } from "../utils"; +import { + deleteOwnedFile, + findOwnedContentIds, + uploadOwnedFile, +} from "../utils"; export default factories.createCoreService( "plugin::better-auth.organization", () => ({ + /** + * Uploads a logo image to the media library, linked to the uploading + * user rather than a specific organization — better-auth-ui's + * `organization.logo.upload` config has no way to pass which org the + * logo is for (it's set on the org afterwards via better-auth's own + * `organization.update`, which already enforces the + * owner/admin-only `organization:update` permission for that org). + */ + async uploadLogo({ + userId, + file, + }: { + userId: string | number; + file: unknown; + }) { + return uploadOwnedFile({ + userId, + file, + field: "logo", + name: `logo-${userId}`, + }); + }, + + /** + * Removes a previously uploaded logo from the media library, but only + * if the caller is allowed to — see `deleteOwnedFile`. + */ + async deleteLogo({ + userId, + url, + headers, + }: { + userId: string | number; + url: string; + headers: Headers; + }) { + return deleteOwnedFile({ userId, url, headers }); + }, + async getRelatedContent({ organizationId, uid, @@ -17,26 +60,49 @@ export default factories.createCoreService( uid: UID; query?: Modules.Documents.ServiceParams["findMany"]; }) { - const contentTypeName = extractContentTypeName(uid); - - const IDs: Record[] = await strapi.db - .connection(`${contentTypeName}s`) - .select("id") - .where({ - owner_id: organizationId, - owner_type: "plugin::better-auth.organization", - }); + const IDs = await findOwnedContentIds( + uid, + organizationId, + "plugin::better-auth.organization", + ); return strapi.documents(uid).findMany({ ...query, filters: { ...query?.filters, id: { - $in: IDs.map( - (row) => row.id, - ) as Modules.Documents.Params.Attribute.ID[], + $in: IDs as Modules.Documents.Params.Attribute.ID[], + }, + }, + }); + }, + + /** + * Organizations `userId` is an owner/admin of — i.e. allowed to + * submit content on behalf of. Backs `GET /organizations/mine`, used + * by the plugin/template submission forms' "Submit as" selector. + */ + async getManageable({ userId }: { userId: string | number }) { + const memberships = await strapi + .documents("plugin::better-auth.member") + .findMany({ + filters: { + userId, + role: { $in: ["admin", "owner"] }, + }, + fields: ["organizationId"], + }); + + if (memberships.length === 0) return []; + + return strapi.documents("plugin::better-auth.organization").findMany({ + filters: { + id: { + $in: memberships.map((membership) => membership.organizationId), }, }, + fields: ["id", "name", "slug", "logo"], + sort: { name: "asc" }, }); }, @@ -70,5 +136,46 @@ export default factories.createCoreService( return users; }, + + /** + * Upserts the `api::profile.profile` linked to an organization — + * most organizations won't have one yet, since nothing creates it + * automatically when an org is created. + */ + async updateProfile({ + organizationId, + data, + }: { + organizationId: string; + data: Record; + }) { + const [organization] = await strapi + .documents("plugin::better-auth.organization") + .findMany({ + filters: { id: organizationId }, + fields: ["documentId"], + populate: { profile: { fields: ["documentId"] } }, + }); + + if (!organization) return null; + + if (organization.profile) { + return strapi.documents("api::profile.profile").update({ + documentId: organization.profile.documentId, + data, + }); + } + + const profile = await strapi.documents("api::profile.profile").create({ + data, + }); + + await strapi.documents("plugin::better-auth.organization").update({ + documentId: organization.documentId, + data: { profile: profile.documentId }, + }); + + return profile; + }, }), ); diff --git a/apps/cms/src/extensions/better-auth/services/submissions.ts b/apps/cms/src/extensions/better-auth/services/submissions.ts new file mode 100644 index 00000000..a6a330fc --- /dev/null +++ b/apps/cms/src/extensions/better-auth/services/submissions.ts @@ -0,0 +1,241 @@ +/** + * submissions service — backs the web app's "Submissions" settings tab + * (list/transfer/delete of packages & templates a user or organization + * owns; plain field edits go through the core package/template `update` + * routes instead — see their controller overrides). + */ + +import type { UID } from "@strapi/strapi"; +import { + deleteSingleOwnedPackage, + deleteSingleOwnedTemplate, + findOwnedContentIds, + type OwnerType, +} from "../utils"; + +const PACKAGE_UID: UID.ContentType = "api::package.package"; +const TEMPLATE_UID: UID.ContentType = "api::template.template"; + +/** The list view's thumbnail — `icon` for packages, `preview_image` for templates. */ +function imagePopulate(uid: UID.ContentType): Record { + return uid === PACKAGE_UID ? { icon: true } : { preview_image: true }; +} + +type SubmissionRow = Record & { isOwner: boolean }; + +async function findRowsByIds(uid: UID.ContentType, ids: number[]) { + if (!ids.length) return []; + return strapi.documents(uid).findMany({ + status: "draft", + filters: { id: { $in: ids } }, + populate: imagePopulate(uid), + }); +} + +/** + * Merges a user's owned + maintained rows for one content type into a + * single deduplicated list, each tagged `isOwner` so the web app can grey + * out edit affordances for rows the caller only maintains. + */ +function mergeOwnedAndMaintained( + ownedIds: number[], + owned: Awaited>, + maintained: Awaited>, +): SubmissionRow[] { + const ownedIdSet = new Set(ownedIds.map(String)); + const merged = new Map(); + + for (const entry of owned) { + merged.set(entry.documentId, { ...entry, isOwner: true }); + } + for (const entry of maintained) { + if (merged.has(entry.documentId)) continue; + merged.set(entry.documentId, { + ...entry, + isOwner: ownedIdSet.has(String(entry.id)), + }); + } + + return Array.from(merged.values()); +} + +export default ({ strapi }) => ({ + /** + * Everything a user owns, plus everything they merely maintain (which + * may belong to someone else, including an organization) — maintainers + * get read visibility in their own `/account/submissions`, never write + * access (enforced by `is-content-owner`, not by this listing). Includes + * drafts/unpublished submissions, unlike the public + * `GET /users/:id/related-content` route. + */ + async getUserSubmissions(userId: string | number) { + const [ownedPackageIds, ownedTemplateIds] = await Promise.all([ + findOwnedContentIds(PACKAGE_UID, userId, "plugin::better-auth.user"), + findOwnedContentIds(TEMPLATE_UID, userId, "plugin::better-auth.user"), + ]); + + const [ + ownedPackages, + ownedTemplates, + maintainedPackages, + maintainedTemplates, + ] = await Promise.all([ + findRowsByIds(PACKAGE_UID, ownedPackageIds), + findRowsByIds(TEMPLATE_UID, ownedTemplateIds), + strapi.documents(PACKAGE_UID).findMany({ + status: "draft", + filters: { maintainers: { id: { $eq: userId } } }, + populate: imagePopulate(PACKAGE_UID), + }), + strapi.documents(TEMPLATE_UID).findMany({ + status: "draft", + filters: { maintainers: { id: { $eq: userId } } }, + populate: imagePopulate(TEMPLATE_UID), + }), + ]); + + return { + packages: mergeOwnedAndMaintained( + ownedPackageIds, + ownedPackages, + maintainedPackages, + ), + templates: mergeOwnedAndMaintained( + ownedTemplateIds, + ownedTemplates, + maintainedTemplates, + ), + }; + }, + + /** + * Everything an organization owns. Whether the *caller* may edit it + * (org owner/admin vs. a plain member just viewing) isn't a per-row + * property here — the controller tags every row with the same + * `isOwner` after this returns (see `organizationMine` in + * `controllers/submissions.ts`), since it depends on the caller's own + * role in this one org, not on anything per-package/template. + */ + async getOrganizationSubmissions(organizationId: string | number) { + const [packageIds, templateIds] = await Promise.all([ + findOwnedContentIds( + PACKAGE_UID, + organizationId, + "plugin::better-auth.organization", + ), + findOwnedContentIds( + TEMPLATE_UID, + organizationId, + "plugin::better-auth.organization", + ), + ]); + + const [packages, templates] = await Promise.all([ + findRowsByIds(PACKAGE_UID, packageIds), + findRowsByIds(TEMPLATE_UID, templateIds), + ]); + + return { packages, templates }; + }, + + /** + * Moves `owner` to a new user/organization, immediately (no acceptance + * flow, per product decision) — same relation shape the admin + * `owner-selector` plugin's `setOwner` action writes, but reachable from + * the content API and gated by `is-content-owner` rather than being + * admin-only. + * + * Updates the draft row unconditionally, and the published row too if + * one already exists — otherwise a still-unapproved submission's owner + * would only "take" once it's first published, and a previously + * transferred, already-live submission would keep showing its old owner + * publicly until its next edit. + */ + async transferOwnership({ + uid, + documentId, + ownerDocumentId, + ownerType, + }: { + uid: UID.ContentType; + documentId: string; + ownerDocumentId: string; + ownerType: OwnerType; + }) { + const ownerEntity = await strapi.db + .query(ownerType) + .findOne({ where: { documentId: ownerDocumentId } }); + if (!ownerEntity) throw new Error("Owner not found"); + + const data = { owner: { id: ownerEntity.id, __type: ownerType } }; + + await strapi.documents(uid).update({ status: "draft", documentId, data }); + + const published = await strapi + .documents(uid) + .findOne({ documentId, status: "published", fields: ["documentId"] }); + if (published) { + await strapi + .documents(uid) + .update({ status: "published", documentId, data }); + } + + return ownerEntity; + }, + + /** Deletes one owned package/template — see `deleteSingleOwned{Package,Template}`. */ + async deleteSubmission(uid: UID.ContentType, documentId: string) { + if (uid === PACKAGE_UID) return deleteSingleOwnedPackage(documentId); + if (uid === TEMPLATE_UID) return deleteSingleOwnedTemplate(documentId); + throw new Error(`Unsupported content type: ${uid}`); + }, + + /** + * Backs both the maintainer picker and the transfer-target ("owner") + * picker on the edit form — an exact, single-result lookup by slug + * (never partial/prefix matching, and never matched on email/name), so + * this can't be used to enumerate registered emails or search the user + * base. A user's slug is already public (it's their profile URL), so + * this reveals nothing a visitor to `/` couldn't already see. + */ + async findUserBySlug(slug: string) { + const trimmed = slug.trim(); + if (!trimmed) return null; + + const [user] = await strapi.documents("plugin::better-auth.user").findMany({ + filters: { slug: { $eq: trimmed } }, + fields: ["documentId", "name", "slug", "image"], + pagination: { pageSize: 1 }, + }); + + if (!user) return null; + return { + documentId: user.documentId, + name: user.name, + slug: user.slug, + image: user.image ?? null, + }; + }, + + /** Same as `findUserBySlug`, for transferring to an organization instead of a user. */ + async findOrganizationBySlug(slug: string) { + const trimmed = slug.trim(); + if (!trimmed) return null; + + const [organization] = await strapi + .documents("plugin::better-auth.organization") + .findMany({ + filters: { slug: { $eq: trimmed } }, + fields: ["documentId", "name", "slug", "logo"], + pagination: { pageSize: 1 }, + }); + + if (!organization) return null; + return { + documentId: organization.documentId, + name: organization.name, + slug: organization.slug, + image: organization.logo ?? null, + }; + }, +}); diff --git a/apps/cms/src/extensions/better-auth/services/user.ts b/apps/cms/src/extensions/better-auth/services/user.ts index 6db84168..f86a8710 100644 --- a/apps/cms/src/extensions/better-auth/services/user.ts +++ b/apps/cms/src/extensions/better-auth/services/user.ts @@ -3,9 +3,98 @@ */ import { factories, type Modules, type UID } from "@strapi/strapi"; -import { extractContentTypeName } from "../utils"; +import { + deleteOwnedFile, + findOwnedContentIds, + uploadOwnedFile, +} from "../utils"; export default factories.createCoreService("plugin::better-auth.user", () => ({ + /** Uploads an avatar image to the media library, linked to its owning user. */ + async uploadAvatar({ + userId, + file, + }: { + userId: string | number; + file: unknown; + }) { + return uploadOwnedFile({ + userId, + file, + field: "avatar", + name: `avatar-${userId}`, + }); + }, + + /** + * Removes a previously uploaded avatar from the media library, but only + * if it belongs to this user. + */ + async deleteAvatar({ + userId, + url, + headers, + }: { + userId: string | number; + url: string; + headers: Headers; + }) { + return deleteOwnedFile({ userId, url, headers }); + }, + + /** + * Reads the `api::profile.profile` linked to a user, if any — used by + * the web app's personal "Profile" tab. + */ + async getProfile({ userId }: { userId: string | number }) { + const [user] = await strapi.documents("plugin::better-auth.user").findMany({ + filters: { id: userId }, + fields: ["documentId"], + populate: { profile: {} }, + }); + + return user?.profile ?? null; + }, + + /** + * Upserts the `api::profile.profile` linked to a user — mirrors the + * organization service's `updateProfile`; most users won't have one + * yet, since nothing creates it automatically when a user signs up. + */ + async updateProfile({ + userId, + data, + }: { + userId: string | number; + data: Record; + }) { + const [user] = await strapi.documents("plugin::better-auth.user").findMany({ + filters: { id: userId }, + fields: ["documentId"], + populate: { profile: { fields: ["documentId"] } }, + }); + + if (!user) return null; + + if (user.profile) { + return strapi.documents("api::profile.profile").update({ + documentId: user.profile.documentId, + data, + }); + } + + const profile = await strapi.documents("api::profile.profile").create({ + data, + }); + + await strapi.documents("plugin::better-auth.user").update({ + documentId: user.documentId, + data: { profile: profile.documentId }, + }); + + return profile; + }, + async getRelatedContent({ organizationId, uid, @@ -15,24 +104,18 @@ export default factories.createCoreService("plugin::better-auth.user", () => ({ uid: UID; query?: Modules.Documents.ServiceParams["findMany"]; }) { - const contentTypeName = extractContentTypeName(uid); - - const IDs: Record[] = await strapi.db - .connection(`${contentTypeName}s`) - .select("id") - .where({ - owner_id: organizationId, - owner_type: "plugin::better-auth.user", - }); + const IDs = await findOwnedContentIds( + uid, + organizationId, + "plugin::better-auth.user", + ); const ownedItems = await strapi.documents(uid).findMany({ ...query, filters: { ...query?.filters, id: { - $in: IDs.map( - (row) => row.id, - ) as Modules.Documents.Params.Attribute.ID[], + $in: IDs as Modules.Documents.Params.Attribute.ID[], }, }, }); diff --git a/apps/cms/src/extensions/better-auth/strapi-server.ts b/apps/cms/src/extensions/better-auth/strapi-server.ts index 8731e90e..ac0a4dbc 100644 --- a/apps/cms/src/extensions/better-auth/strapi-server.ts +++ b/apps/cms/src/extensions/better-auth/strapi-server.ts @@ -1,22 +1,38 @@ import organizationController from "./controllers/organization"; +import submissionsController from "./controllers/submissions"; import userController from "./controllers/user"; +import isAuthenticated from "./policies/is-authenticated"; +import isContentOwner from "./policies/is-content-owner"; +import isOrganizationMember from "./policies/is-organization-member"; +import isOrganizationOwner from "./policies/is-organization-owner"; import organizationRoutes from "./routes/organization"; +import submissionsRoutes from "./routes/submissions"; import userRoutes from "./routes/user"; import organizationService from "./services/organization"; +import submissionsService from "./services/submissions"; import userService from "./services/user"; export default (plugin) => { plugin.controllers.organization = organizationController; + plugin.controllers.submissions = submissionsController; plugin.controllers.user = userController; if (!plugin.services) plugin.services = {}; plugin.services.organization = organizationService; + plugin.services.submissions = submissionsService; plugin.services.user = userService; + if (!plugin.policies) plugin.policies = {}; + plugin.policies["is-authenticated"] = isAuthenticated; + plugin.policies["is-content-owner"] = isContentOwner; + plugin.policies["is-organization-member"] = isOrganizationMember; + plugin.policies["is-organization-owner"] = isOrganizationOwner; + plugin.routes["content-api"] = { type: "content-api", routes: [ ...organizationRoutes["content-api"].routes, + ...submissionsRoutes["content-api"].routes, ...userRoutes["content-api"].routes, ], }; diff --git a/apps/cms/src/extensions/better-auth/utils/cascade-delete.ts b/apps/cms/src/extensions/better-auth/utils/cascade-delete.ts new file mode 100644 index 00000000..8cf8ecee --- /dev/null +++ b/apps/cms/src/extensions/better-auth/utils/cascade-delete.ts @@ -0,0 +1,442 @@ +import type { UID } from "@strapi/strapi"; +import { extractContentTypeName } from "./content-type-name"; + +export type OwnerType = + | "plugin::better-auth.user" + | "plugin::better-auth.organization"; + +/** + * Finds the raw ids of every entry of `uid` whose polymorphic `owner` + * (`owner_type`/`owner_id` — a `morphToOne`, which Strapi's own query/document + * services can't filter on directly) points at `ownerId`/`ownerType`. Shared + * by the org/user services' own `getRelatedContent` and the cascade-delete + * helpers below, so the raw lookup only lives in one place. + */ +export async function findOwnedContentIds( + uid: UID.ContentType, + ownerId: string | number, + ownerType: OwnerType, +): Promise { + const contentTypeName = extractContentTypeName(uid); + + const rows: { id: number }[] = await strapi.db + .connection(`${contentTypeName}s`) + .select("id") + .where({ owner_id: ownerId, owner_type: ownerType }); + + return rows.map((row) => row.id); +} + +/** + * Finds the `owner_id`/`owner_type` of a single entry by `documentId` — the + * single-entry counterpart to `findOwnedContentIds` above, for callers that + * already know which document they mean (the `is-content-owner` policy) and + * just need to find out *what kind* of owner it has, since a populated + * `owner` relation doesn't say which concrete type it resolved to. + * + * A draftAndPublish document can have both a draft and a published row; + * they're kept in sync on transfer (`submissions.transferOwnership`) and + * whenever the draft is republished after an edit, but prefer the + * published row here regardless — it's the live, authoritative owner for + * anyone still mid-edit on a stale draft. + */ +export async function findContentOwner( + uid: UID.ContentType, + documentId: string, +): Promise<{ id: number; type: OwnerType } | null> { + const contentTypeName = extractContentTypeName(uid); + + const rows: { + owner_id: number; + owner_type: OwnerType; + published_at: string | null; + }[] = await strapi.db + .connection(`${contentTypeName}s`) + .select("owner_id", "owner_type", "published_at") + .where({ document_id: documentId }); + + const row = rows.find((r) => r.published_at !== null) ?? rows[0]; + if (!row?.owner_id) return null; + return { id: row.owner_id, type: row.owner_type }; +} + +/** Deletes a media file (looked up by id) via the upload plugin, if it exists. */ +async function deleteMediaById(id: number) { + const file = await strapi.db + .query("plugin::upload.file") + .findOne({ where: { id } }); + if (file) await strapi.plugin("upload").service("upload").remove(file); +} + +/** + * Deletes one package, along with what would otherwise be left orphaned + * once it's gone: its business/security reviews, its `url_alias` redirect + * entries, and its icon. Shared by the bulk `deleteOwnedPackages` cascade + * below and the single-entry "delete my submission" action + * (`extensions/better-auth/services/submissions.ts`). + */ +export async function deleteSingleOwnedPackage(documentId: string) { + const entry = await strapi.documents("api::package.package").findOne({ + documentId, + fields: ["documentId"], + populate: { + icon: { fields: ["id"] }, + business_review: { fields: ["documentId"] }, + security_reviews: { fields: ["documentId"] }, + url_alias: { fields: ["documentId"] }, + }, + }); + if (!entry) return; + + if (entry.business_review) { + await strapi + .documents("plugin::moderation.business-review") + .delete({ documentId: entry.business_review.documentId }); + } + + for (const review of entry.security_reviews ?? []) { + await strapi + .documents("plugin::moderation.security-review") + .delete({ documentId: review.documentId }); + } + + for (const alias of entry.url_alias ?? []) { + await strapi + .documents("plugin::webtools.url-alias") + .delete({ documentId: alias.documentId }); + } + + if (entry.icon) await deleteMediaById(entry.icon.id); + + await strapi + .documents("api::package.package") + .delete({ documentId: entry.documentId }); +} + +/** + * Deletes every package owned by a user/organization, along with what would + * otherwise be left orphaned once it's gone — see `deleteSingleOwnedPackage`. + */ +async function deleteOwnedPackages( + ownerId: string | number, + ownerType: OwnerType, +) { + const ids = await findOwnedContentIds( + "api::package.package", + ownerId, + ownerType, + ); + if (!ids.length) return; + + const entries = await strapi.documents("api::package.package").findMany({ + filters: { id: { $in: ids } }, + fields: ["documentId"], + }); + + for (const entry of entries) { + await deleteSingleOwnedPackage(entry.documentId); + } +} + +/** Same as `deleteSingleOwnedPackage`, for templates (whose media field is `preview_image`). */ +export async function deleteSingleOwnedTemplate(documentId: string) { + const entry = await strapi.documents("api::template.template").findOne({ + documentId, + fields: ["documentId"], + populate: { + preview_image: { fields: ["id"] }, + business_review: { fields: ["documentId"] }, + security_reviews: { fields: ["documentId"] }, + url_alias: { fields: ["documentId"] }, + }, + }); + if (!entry) return; + + if (entry.business_review) { + await strapi + .documents("plugin::moderation.business-review") + .delete({ documentId: entry.business_review.documentId }); + } + + for (const review of entry.security_reviews ?? []) { + await strapi + .documents("plugin::moderation.security-review") + .delete({ documentId: review.documentId }); + } + + for (const alias of entry.url_alias ?? []) { + await strapi + .documents("plugin::webtools.url-alias") + .delete({ documentId: alias.documentId }); + } + + if (entry.preview_image) await deleteMediaById(entry.preview_image.id); + + await strapi + .documents("api::template.template") + .delete({ documentId: entry.documentId }); +} + +/** Same as `deleteOwnedPackages`, for templates. */ +async function deleteOwnedTemplates( + ownerId: string | number, + ownerType: OwnerType, +) { + const ids = await findOwnedContentIds( + "api::template.template", + ownerId, + ownerType, + ); + if (!ids.length) return; + + const entries = await strapi.documents("api::template.template").findMany({ + filters: { id: { $in: ids } }, + fields: ["documentId"], + }); + + for (const entry of entries) { + await deleteSingleOwnedTemplate(entry.documentId); + } +} + +/** + * Same idea, for showcases — no `business_review`/`security_reviews`/ + * `url_alias` on this content type (webtools is disabled for it), just the + * entry and its `image`. + */ +async function deleteOwnedShowcases( + ownerId: string | number, + ownerType: OwnerType, +) { + const ids = await findOwnedContentIds( + "api::showcase.showcase", + ownerId, + ownerType, + ); + if (!ids.length) return; + + const entries = await strapi.documents("api::showcase.showcase").findMany({ + filters: { id: { $in: ids } }, + fields: ["documentId"], + populate: { image: { fields: ["id"] } }, + }); + + for (const entry of entries) { + if (entry.image) await deleteMediaById(entry.image.id); + + await strapi + .documents("api::showcase.showcase") + .delete({ documentId: entry.documentId }); + } +} + +/** + * Deletes every package/template/showcase owned by a user or organization, + * along with everything that would otherwise be left orphaned once the + * entry itself is gone. Used by both `cascadeDeleteOrganization` and + * `cascadeDeleteUser`. + */ +export async function deleteOwnedContent({ + ownerId, + ownerType, +}: { + ownerId: string | number; + ownerType: OwnerType; +}) { + await deleteOwnedPackages(ownerId, ownerType); + await deleteOwnedTemplates(ownerId, ownerType); + await deleteOwnedShowcases(ownerId, ownerType); +} + +/** + * Deletes the `api::profile.profile` linked to a user (if any), their + * avatar file, and their `url_alias` redirect entries. The avatar isn't a + * `media` attribute — like `deleteOwnedFile` (`utils/index.ts`) already + * relies on, it's tracked only by URL (`user.image`), looked up by that URL + * on `plugin::upload.file`. + */ +export async function deleteUserProfileAndAvatar(userId: string | number) { + const [user] = await strapi.documents("plugin::better-auth.user").findMany({ + filters: { id: userId }, + fields: ["documentId", "image"], + populate: { + profile: { fields: ["documentId"] }, + url_alias: { fields: ["documentId"] }, + }, + }); + if (!user) return; + + if (user.profile) { + await strapi + .documents("api::profile.profile") + .delete({ documentId: user.profile.documentId }); + } + + for (const alias of user.url_alias ?? []) { + await strapi + .documents("plugin::webtools.url-alias") + .delete({ documentId: alias.documentId }); + } + + if (user.image) { + const file = await strapi.db + .query("plugin::upload.file") + .findOne({ where: { url: user.image } }); + if (file) await strapi.plugin("upload").service("upload").remove(file); + } +} + +/** Same as `deleteUserProfileAndAvatar`, for organizations (`logo` instead of `image`). */ +export async function deleteOrganizationProfileAndLogo( + organizationId: string | number, +) { + const [organization] = await strapi + .documents("plugin::better-auth.organization") + .findMany({ + filters: { id: organizationId }, + fields: ["documentId", "logo"], + populate: { + profile: { fields: ["documentId"] }, + url_alias: { fields: ["documentId"] }, + }, + }); + if (!organization) return; + + if (organization.profile) { + await strapi + .documents("api::profile.profile") + .delete({ documentId: organization.profile.documentId }); + } + + for (const alias of organization.url_alias ?? []) { + await strapi + .documents("plugin::webtools.url-alias") + .delete({ documentId: alias.documentId }); + } + + if (organization.logo) { + const file = await strapi.db + .query("plugin::upload.file") + .findOne({ where: { url: organization.logo } }); + if (file) await strapi.plugin("upload").service("upload").remove(file); + } +} + +/** + * Removes `userId` from the `maintainers` list of any package/template they + * don't own but still maintain — content itself is left untouched. Uses the + * lower-level `strapi.db.query` (rather than the document service) since it + * takes plain relation ids directly, mirroring `relinkOrganizationLogo`'s + * own relation-array update in `lib/auth.ts`. + */ +async function stripMaintainer(userId: string | number) { + for (const uid of [ + "api::package.package", + "api::template.template", + ] as const) { + const entries: { id: number }[] = await strapi.db.query(uid).findMany({ + where: { maintainers: { id: userId } }, + select: ["id"], + }); + + for (const entry of entries) { + await strapi.db.query(uid).update({ + where: { id: entry.id }, + data: { maintainers: { disconnect: [{ id: userId }] } }, + }); + } + } +} + +/** + * Cascade-deletes everything an organization owns, ahead of the org itself + * being deleted — hooked into `organizationHooks.beforeDeleteOrganization` + * in `lib/auth.ts`. Deliberately does NOT delete the org's own `member`, + * `invitation`, or `organization` rows: better-auth's own + * `deleteOrganization` adapter method already does that immediately after + * this hook runs (see `better-auth/plugins/organization/adapter.mjs`). + */ +export async function cascadeDeleteOrganization( + organizationId: string | number, +) { + await deleteOwnedContent({ + ownerId: organizationId, + ownerType: "plugin::better-auth.organization", + }); + + await deleteOrganizationProfileAndLogo(organizationId); +} + +/** + * Cascade-deletes everything a user owns, ahead of the user itself being + * deleted — hooked into `user.deleteUser.beforeDelete` in `lib/auth.ts`. + * Unlike org deletion, better-auth's own `deleteUser` never touches + * `member`/`invitation` rows at all, so this handles organization + * membership itself too: any org the user owns is fully cascade-deleted + * (its other members lose it — a deliberate product decision, no + * ownership auto-transfer); any org they're just a member/admin of just + * has their membership removed. + */ +export async function cascadeDeleteUser(userId: string | number) { + await deleteOwnedContent({ + ownerId: userId, + ownerType: "plugin::better-auth.user", + }); + + await deleteUserProfileAndAvatar(userId); + + const memberships = await strapi + .documents("plugin::better-auth.member") + .findMany({ + filters: { userId }, + fields: ["documentId", "organizationId", "role"], + }); + + for (const membership of memberships) { + if (membership.role !== "owner") { + await strapi + .documents("plugin::better-auth.member") + .delete({ documentId: membership.documentId }); + continue; + } + + // Sole/co-owner leaving via account deletion — cascade-delete the + // whole organization, replicating better-auth's own + // `deleteOrganization` cleanup (member + invitation rows, then the + // organization itself) since that endpoint is bypassed on this path. + await cascadeDeleteOrganization(membership.organizationId); + + const [remainingMembers, invitations, [organization]] = await Promise.all([ + strapi.documents("plugin::better-auth.member").findMany({ + filters: { organizationId: membership.organizationId }, + fields: ["documentId"], + }), + strapi.documents("plugin::better-auth.invitation").findMany({ + filters: { organizationId: membership.organizationId }, + fields: ["documentId"], + }), + strapi.documents("plugin::better-auth.organization").findMany({ + filters: { id: membership.organizationId }, + fields: ["documentId"], + }), + ]); + + for (const member of remainingMembers) { + await strapi + .documents("plugin::better-auth.member") + .delete({ documentId: member.documentId }); + } + for (const invitation of invitations) { + await strapi + .documents("plugin::better-auth.invitation") + .delete({ documentId: invitation.documentId }); + } + if (organization) { + await strapi + .documents("plugin::better-auth.organization") + .delete({ documentId: organization.documentId }); + } + } + + await stripMaintainer(userId); +} diff --git a/apps/cms/src/extensions/better-auth/utils/content-type-name.ts b/apps/cms/src/extensions/better-auth/utils/content-type-name.ts new file mode 100644 index 00000000..b5549029 --- /dev/null +++ b/apps/cms/src/extensions/better-auth/utils/content-type-name.ts @@ -0,0 +1,8 @@ +import type { UID } from "@strapi/strapi"; + +/** + * Factored out of the utils barrel (`./index`) so `cascade-delete.ts` can + * import it without creating a circular import with that barrel. + */ +export const extractContentTypeName = (uid: UID.ContentType) => + uid.split(".")[1]; diff --git a/apps/cms/src/extensions/better-auth/utils/index.ts b/apps/cms/src/extensions/better-auth/utils/index.ts index bee25b34..b9b12992 100644 --- a/apps/cms/src/extensions/better-auth/utils/index.ts +++ b/apps/cms/src/extensions/better-auth/utils/index.ts @@ -1,8 +1,23 @@ import type { UID } from "@strapi/strapi"; +import { auth } from "../../../lib/auth"; import type services from "../services"; +import { sanitizeIfSvg } from "./sanitize-svg"; -export const extractContentTypeName = (uid: UID.ContentType) => - uid.split(".")[1]; +export { + cascadeDeleteOrganization, + cascadeDeleteUser, + deleteOrganizationProfileAndLogo, + deleteOwnedContent, + deleteSingleOwnedPackage, + deleteSingleOwnedTemplate, + deleteUserProfileAndAvatar, + findContentOwner, + findOwnedContentIds, + type OwnerType, +} from "./cascade-delete"; +export { extractContentTypeName } from "./content-type-name"; +export { validateProfileData } from "./profile-validation"; +export { sanitizeIfSvg } from "./sanitize-svg"; /** * A helper function to obtain a plugin service. @@ -22,3 +37,173 @@ export const communityContentTypes: UID.ContentType[] = [ "api::package.package", "api::template.template", ]; + +/** + * Checks whether the caller (identified via `headers`, i.e. their + * better-auth session cookie) has `organization: ["update"]` permission on + * `organizationId` — true for an owner/admin of that org. Every + * owner/admin check in this codebase should go through this, not call + * `auth.api.hasPermission` directly: confirmed directly against a real + * session that it *throws* (`APIError: Unauthorized`) rather than + * resolving to `{ success: false }` when the caller has no membership in + * the target organization at all — a real case (e.g. a package/template + * maintainer who isn't a member of the org that owns it), not an + * exceptional one, and left uncaught it surfaces as a 500 instead of the + * "not authorized" it actually is. + */ +export async function hasOrganizationUpdatePermission( + headers: Headers, + organizationId: string, +): Promise { + try { + const { success } = await auth.api.hasPermission({ + headers, + body: { + organizationId, + permissions: { organization: ["update"] }, + }, + }); + return success; + } catch { + return false; + } +} + +/** + * Is `userId` a member of `organizationId` at all — owner, admin, or + * plain member? Unlike `hasOrganizationUpdatePermission`, this is a read + * check, not a write one: used to gate *visibility* (e.g. the + * "Submissions" list, or viewing a submission read-only) where every + * member should see the org's content, even though only an owner/admin + * may edit it. + */ +export async function isOrganizationMember( + userId: string | number, + organizationId: string | number, +): Promise { + const [membership] = await strapi + .documents("plugin::better-auth.member") + .findMany({ + filters: { organizationId, userId }, + fields: ["documentId"], + pagination: { pageSize: 1 }, + }); + return Boolean(membership); +} + +/** + * `api::profile.profile` fields an org owner/admin may edit through the + * organization "Profile" tab — scoped to exactly what the public + * organization page renders (bio/subtitle/website/github/location/readme). + */ +export const organizationProfileFields = [ + "bio", + "subtitle", + "website", + "github", + "location", + "readme", +] as const; + +/** + * `api::profile.profile` fields a user may edit through their personal + * "Profile" tab — scoped to exactly what the public user page renders + * (bio/subtitle/website/github/location/email/readme). Includes `email` + * (a public contact address), unlike `organizationProfileFields` — an + * organization has no equivalent distinct-from-account address. + */ +export const userProfileFields = [ + "bio", + "subtitle", + "website", + "github", + "location", + "email", + "readme", +] as const; + +/** + * Uploads a file to the media library, linked to the uploading user via + * the native `related` morph field on `plugin::upload.file` — the same + * mechanism Strapi's own upload plugin uses internally for + * `POST /api/upload?ref=X&refId=Y&field=Z`. Used for avatar/logo uploads, + * where ownership needs to be checked later (`deleteOwnedFile`) but the + * file isn't attached to a real `type: "media"` attribute anywhere. + */ +export async function uploadOwnedFile({ + userId, + file, + field, + name, +}: { + userId: string | number; + file: unknown; + field: string; + name: string; +}) { + await sanitizeIfSvg(file as { filepath?: string; mimetype?: string }); + + const [uploaded] = await strapi + .plugin("upload") + .service("upload") + .upload({ + data: { + fileInfo: { name }, + ref: "plugin::better-auth.user", + refId: userId, + field, + }, + files: file, + }); + + return uploaded as { url: string }; +} + +/** + * Removes a previously uploaded file from the media library, but only if + * the caller is allowed to: either they're the user it's directly + * related to (via `uploadOwnedFile` — true for avatars, and briefly true + * for logos before they're attached to an org), or the file is related + * to an organization and they're an owner/admin of that org (true for + * logos once `relinkOrganizationLogo` in `lib/auth.ts` re-tags them). + */ +export async function deleteOwnedFile({ + userId, + url, + headers, +}: { + userId: string | number; + url: string; + headers: Headers; +}) { + const existing = await strapi.db.query("plugin::upload.file").findOne({ + where: { url }, + populate: ["related"], + }); + if (!existing) return false; + + const related = + (existing.related as { id: string | number; __type: string }[]) ?? []; + + const isOwnFile = related.some( + (item) => + item.__type === "plugin::better-auth.user" && + String(item.id) === String(userId), + ); + + const relatedOrganization = related.find( + (item) => item.__type === "plugin::better-auth.organization", + ); + + const canManageRelatedOrganization = + relatedOrganization && + (await hasOrganizationUpdatePermission( + headers, + String(relatedOrganization.id), + )); + + if (!isOwnFile && !canManageRelatedOrganization) return false; + + await strapi.plugin("upload").service("upload").remove(existing); + return true; +} diff --git a/apps/cms/src/extensions/better-auth/utils/profile-validation.ts b/apps/cms/src/extensions/better-auth/utils/profile-validation.ts new file mode 100644 index 00000000..ed5905ad --- /dev/null +++ b/apps/cms/src/extensions/better-auth/utils/profile-validation.ts @@ -0,0 +1,66 @@ +/** + * Format rules for the `api::profile.profile` fields editable through the + * web app's personal/organization "Profile" tabs. The web app already + * enforces these inline before submitting + * (apps/web/src/features/auth/components/profile-view/validation.ts) — + * this is the server-side backstop for anyone calling the API directly. + * Keep the two in sync if either changes. + * + * Length limits (bio, subtitle) aren't here — they're `maxLength` on the + * `api::profile.profile` schema itself + * (apps/cms/src/api/profile/content-types/profile/schema.json), enforced + * by Strapi on every write path. This file only covers format rules a + * schema attribute can't express. + */ + +/** `https://github.com/` only — not a repo, gist, or org team link. */ +const GITHUB_PROFILE_PATTERN = + /^https:\/\/(www\.)?github\.com\/[a-zA-Z\d](?:[a-zA-Z\d]|-(?=[a-zA-Z\d])){0,38}\/?$/; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +const VALIDATORS: Record string | undefined> = { + website: (value) => + isHttpUrl(value) + ? undefined + : "Website must be a valid URL, e.g. https://example.com.", + github: (value) => + GITHUB_PROFILE_PATTERN.test(value) + ? undefined + : "GitHub must be a valid GitHub profile URL, e.g. https://github.com/your-username.", + email: (value) => + EMAIL_PATTERN.test(value) + ? undefined + : "Public email must be a valid email address.", +}; + +/** + * Validates whichever of the fields above are present in `data`. Fields + * are optional — omitting one, or sending it empty/null to clear it, is + * always valid — but any non-empty value sent for one of them must pass + * its format rule. + */ +export function validateProfileData(data: Record): string[] { + const errors: string[] = []; + + for (const [field, validate] of Object.entries(VALIDATORS)) { + if (!(field in data)) continue; + + const value = data[field]; + if (typeof value !== "string" || value.trim() === "") continue; + + const error = validate(value.trim()); + if (error) errors.push(error); + } + + return errors; +} diff --git a/apps/cms/src/extensions/better-auth/utils/sanitize-svg.ts b/apps/cms/src/extensions/better-auth/utils/sanitize-svg.ts new file mode 100644 index 00000000..befb4bda --- /dev/null +++ b/apps/cms/src/extensions/better-auth/utils/sanitize-svg.ts @@ -0,0 +1,40 @@ +import { readFile, writeFile } from "node:fs/promises"; +import DOMPurify from "isomorphic-dompurify"; + +const SVG_MIME = "image/svg+xml"; + +/** + * If `file` (a formidable-parsed upload — see its `filepath`/`mimetype`) is + * an SVG, sanitizes its XML content *in place* on disk before it's handed + * to the upload service. SVG is XML that can carry a `