Add sensitive media backend support - #1958
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR adds sensitive media support end-to-end: new database columns ( Estimated code review effort: 4 (Complex) | ~60 minutes Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant PreferencesController
participant UsersTable
Client->>PreferencesController: GET /preferences
PreferencesController->>UsersTable: SELECT show_sensitive_media WHERE site_id
UsersTable-->>PreferencesController: row
PreferencesController-->>Client: { showSensitiveMedia }
Client->>PreferencesController: PUT /preferences { showSensitiveMedia }
PreferencesController->>PreferencesController: validate payload via UpdatePreferencesSchema
alt invalid JSON or schema
PreferencesController-->>Client: 400
else valid
PreferencesController->>UsersTable: UPDATE show_sensitive_media WHERE site_id
UsersTable-->>PreferencesController: ack
PreferencesController-->>Client: { showSensitiveMedia }
end
Related issues: None specified. Related PRs: None specified. Suggested labels: feature, backend, database, api Suggested reviewers: None specified. Poem A rabbit hopped through columns new, 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Added the backend storage and ingestion groundwork for sensitive media so later API and client slices can rely on a persisted post-level flag.
Exposes persisted sensitive flags and remote content-warning labels through additive ActivityPub API response fields so clients can hide or reveal sensitive media without conflating Ghost custom excerpts with content warnings.
Exposes the persisted sensitive media display preference through the ActivityPub API so clients can read and update the current site's default reveal behavior.
no ref The cross-repo implementation log needs to record the frontend display slice checks, browser verification, and review deviations so the final review can compare actual work against the original plan.
no ref The settings toggle slice added the final user-facing preference control, so the implementation log now records the red/green tests, review follow-up, and authenticated Admin verification.
Record the UI relocation, verification, and review follow-up so the implementation log reflects the current product direction.
no ref Record the missed-surface fixes, red/green verification, browser limitation, and adversarial review follow-up for the sensitive media implementation log.
no ref Record the final requirement audit, quality gates, and browser verification for the sensitive media implementation.
b1c11ed to
1e62f22
Compare
|
@CodeRabbit review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
src/http/api/helpers/post.unit.test.ts (1)
142-145: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse field-level assertions for the DTO fields under test.
These tests currently assert grouped object fragments; switching to direct field assertions keeps failures focused on the sensitive/content-warning contract.
As per coding guidelines, “Assertions should target the specific field being tested, not stringify the whole object; prefer
expect(result.field).toEqual(...)overexpect(JSON.stringify(result)).toContain(...).”Proposed assertion refactor
- expect(dto).toMatchObject({ - sensitive: true, - contentWarning: null, - }); + expect(dto.sensitive).toBe(true); + expect(dto.contentWarning).toBeNull(); @@ - expect(dto).toMatchObject({ - sensitive: true, - summary: 'Sensitive topic', - contentWarning: 'Sensitive topic', - }); + expect(dto.sensitive).toBe(true); + expect(dto.summary).toBe('Sensitive topic'); + expect(dto.contentWarning).toBe('Sensitive topic'); @@ - expect(dto).toMatchObject({ - sensitive: true, - summary: 'Sensitive topic', - contentWarning: 'Sensitive topic', - }); + expect(dto.sensitive).toBe(true); + expect(dto.summary).toBe('Sensitive topic'); + expect(dto.contentWarning).toBe('Sensitive topic'); @@ - expect(dto).toMatchObject({ - sensitive: true, - summary: 'Custom excerpt', - contentWarning: null, - }); + expect(dto.sensitive).toBe(true); + expect(dto.summary).toBe('Custom excerpt'); + expect(dto.contentWarning).toBeNull();Also applies to: 161-165, 181-185, 200-204
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/api/helpers/post.unit.test.ts` around lines 142 - 145, The DTO tests are using grouped object assertions for the sensitive/content-warning contract instead of field-level checks. Update the relevant cases in post.unit.test.ts to assert each DTO property directly on dto using specific field expectations, and apply the same refactor in the other listed blocks so failures stay focused on the exact field under test.Source: Coding guidelines
src/http/api/preferences.controller.ts (1)
8-12: 📐 Maintainability & Code Quality | 🔵 TrivialConsider
z.strictObject()per Zod v4 idiom.
z.object({...}).strict()still works, but Zod v4 introducesz.strictObject()as the dedicated API for this pattern.♻️ Optional refactor
-const UpdatePreferencesSchema = z - .object({ - showSensitiveMedia: z.boolean(), - }) - .strict(); +const UpdatePreferencesSchema = z.strictObject({ + showSensitiveMedia: z.boolean(), +});🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/api/preferences.controller.ts` around lines 8 - 12, The preferences schema uses z.object(...).strict() in UpdatePreferencesSchema, but this should follow the Zod v4 idiom. Update the schema definition in the preferences controller to use z.strictObject(...) instead, keeping the same showSensitiveMedia field and behavior while preserving the existing schema name for the controller logic.src/feed/feed.service.ts (1)
33-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider extracting the repeated
author_is_internaljoin/CASE fragment.The
LEFT JOIN users as author_user ... CASE WHEN author_user.id IS NOT NULL THEN 1 ELSE 0 END AS author_is_internalblock is duplicated verbatim ingetFeedDataandgetDiscoveryFeedDatahere, and again (twice) insrc/http/api/views/account.posts.view.ts. Extracting a small shared helper (e.g.withAuthorIsInternal(query, authorAccountAlias)) would reduce copy/paste risk if the internal-author detection logic ever changes.Also applies to: 171-176, 205-209, 328-333, 348-352
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/feed/feed.service.ts` around lines 33 - 64, The repeated author_is_internal join/CASE logic is duplicated in feed queries, so centralize it into a shared helper and reuse it everywhere. Extract the LEFT JOIN users as author_user plus the CASE WHEN author_user.id IS NOT NULL THEN 1 ELSE 0 END AS author_is_internal fragment into a helper such as withAuthorIsInternal(query, authorAccountAlias), then call that from getFeedData and getDiscoveryFeedData (and the matching account.posts.view query builders) to keep the internal-author detection logic consistent.src/http/api/notification.controller.unit.test.ts (1)
14-92: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winMissing test coverage for internal-author (Ghost) sensitive posts.
The only scenario tested has
post_author_is_internal: 0/in_reply_to_post_author_is_internal: 0. The PR's key behavioral rule — thatcontentWarningmust staynullfor internal (Ghost) posts even whensensitiveis true and a summary exists — isn't exercised here.✅ Suggested additional test case
it('maps sensitive and content warnings for notification post DTOs', async () => { ... }); + + it('does not derive a content warning for internal (Ghost) sensitive posts', async () => { + (notificationService.getNotificationsData as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ + results: [ + { + // ...same fixture shape... + post_sensitive: 1, + post_summary: 'Internal excerpt', + post_author_is_internal: 1, + in_reply_to_post_sensitive: 1, + in_reply_to_post_summary: 'Internal reply excerpt', + in_reply_to_post_author_is_internal: 1, + }, + ], + nextCursor: null, + }); + + const response = await notificationController.handleGetNotifications(ctx); + const body = await response.json(); + + expect(body.notifications[0].post).toMatchObject({ + sensitive: true, + contentWarning: null, + }); + expect(body.notifications[0].inReplyTo).toMatchObject({ + sensitive: true, + contentWarning: null, + }); + });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/api/notification.controller.unit.test.ts` around lines 14 - 92, Add a new unit test in notification.controller.unit.test.ts alongside handleGetNotifications that covers internal/Ghost authors by setting NotificationService.getNotificationsData to return posts with post_author_is_internal and in_reply_to_post_author_is_internal set to true/1 while sensitive is true and summaries are present. Assert through NotificationController.handleGetNotifications that the mapped post and inReplyTo DTOs keep sensitive true but leave contentWarning null, so the behavior is verified for both main post and reply fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/http/api/preferences.controller.ts`:
- Around line 14-64: PreferencesController is accessing the database directly
instead of going through the service/repository layer, and it also skips the
Result pattern used elsewhere. Move the select/update logic out of
handleGetPreferences and handleUpdatePreferences into a PreferencesService
backed by a repository, and have the controller call that service instead of
using Knex directly. Make the service return Result values and update the
controller to handle those with isError(), getError(), and getValue() before
returning the HTTP response.
---
Nitpick comments:
In `@src/feed/feed.service.ts`:
- Around line 33-64: The repeated author_is_internal join/CASE logic is
duplicated in feed queries, so centralize it into a shared helper and reuse it
everywhere. Extract the LEFT JOIN users as author_user plus the CASE WHEN
author_user.id IS NOT NULL THEN 1 ELSE 0 END AS author_is_internal fragment into
a helper such as withAuthorIsInternal(query, authorAccountAlias), then call that
from getFeedData and getDiscoveryFeedData (and the matching account.posts.view
query builders) to keep the internal-author detection logic consistent.
In `@src/http/api/helpers/post.unit.test.ts`:
- Around line 142-145: The DTO tests are using grouped object assertions for the
sensitive/content-warning contract instead of field-level checks. Update the
relevant cases in post.unit.test.ts to assert each DTO property directly on dto
using specific field expectations, and apply the same refactor in the other
listed blocks so failures stay focused on the exact field under test.
In `@src/http/api/notification.controller.unit.test.ts`:
- Around line 14-92: Add a new unit test in notification.controller.unit.test.ts
alongside handleGetNotifications that covers internal/Ghost authors by setting
NotificationService.getNotificationsData to return posts with
post_author_is_internal and in_reply_to_post_author_is_internal set to true/1
while sensitive is true and summaries are present. Assert through
NotificationController.handleGetNotifications that the mapped post and inReplyTo
DTOs keep sensitive true but leave contentWarning null, so the behavior is
verified for both main post and reply fields.
In `@src/http/api/preferences.controller.ts`:
- Around line 8-12: The preferences schema uses z.object(...).strict() in
UpdatePreferencesSchema, but this should follow the Zod v4 idiom. Update the
schema definition in the preferences controller to use z.strictObject(...)
instead, keeping the same showSensitiveMedia field and behavior while preserving
the existing schema name for the controller logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9843f4e4-9ece-402f-a978-c4037bd33b5b
📒 Files selected for processing (31)
migrate/migrations/000083_add-sensitive-media-support.down.sqlmigrate/migrations/000083_add-sensitive-media-support.up.sqlprogress.mdsrc/app.tssrc/configuration/registrations.tssrc/feed/feed.service.tssrc/http/api/__snapshots__/feed.jsonsrc/http/api/__snapshots__/post-authored-by-me.jsonsrc/http/api/__snapshots__/post-liked-by-me.jsonsrc/http/api/__snapshots__/post-reposted-by-me.jsonsrc/http/api/__snapshots__/post.jsonsrc/http/api/feed.controller.tssrc/http/api/feed.unit.test.tssrc/http/api/helpers/post.tssrc/http/api/helpers/post.unit.test.tssrc/http/api/notification.controller.tssrc/http/api/notification.controller.unit.test.tssrc/http/api/preferences.controller.integration.test.tssrc/http/api/preferences.controller.tssrc/http/api/types.tssrc/http/api/views/account.posts.view.tssrc/http/api/views/account.posts.view.unit.test.tssrc/http/api/views/reply.chain.view.integration.test.tssrc/http/api/views/reply.chain.view.tssrc/notification/__snapshots__/get-notifications-data.jsonsrc/notification/notification.service.tssrc/post/post.entity.tssrc/post/post.repository.knex.integration.test.tssrc/post/post.repository.knex.tssrc/post/post.service.integration.test.tssrc/post/post.service.ts
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/preferences/preferences.repository.knex.ts`:
- Around line 21-30: The updateForSite method in PreferencesRepositoryKnex
ignores the affected-row count from this.db('users').where({ site_id: siteId
}).update(...), so a missing row can look successful. Capture the update result
in updateForSite, verify at least one row was modified, and handle the zero-row
case explicitly (for example by throwing or returning an error/empty result)
before returning PreferencesDTO.
- Around line 10-19: The getForSite method in PreferencesRepository currently
treats a missing users row as showSensitiveMedia=false because
Boolean(user?.show_sensitive_media) silently falls back, which hides invalid
site_id cases. Update getForSite to explicitly handle the .first() result from
the users query: if no row is found, surface that as an error/exception (or
otherwise explicitly signal missing data) instead of returning a default
preference. Keep the normal mapping from show_sensitive_media to PreferencesDTO
only for a real user row.
In `@src/preferences/preferences.service.ts`:
- Line 9: `PreferencesServiceError` is currently typed as `never`, but
`PreferencesService` can fail when repository calls return missing-site/user or
DB errors. Update `PreferencesServiceError` in `PreferencesService` to be a real
error object type with context, and make the service methods that use
`getForSite`/`updateForSite` propagate repository failures through the Result
channel instead of always wrapping everything in `ok(...)`. Ensure the typed
error is used consistently so callers can distinguish successful reads/updates
from not-found or database failure cases.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9560504c-3dad-476c-b859-6959549de23f
📒 Files selected for processing (7)
src/configuration/registrations.tssrc/http/api/helpers/post.unit.test.tssrc/http/api/notification.controller.unit.test.tssrc/http/api/preferences.controller.integration.test.tssrc/http/api/preferences.controller.tssrc/preferences/preferences.repository.knex.tssrc/preferences/preferences.service.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- src/http/api/notification.controller.unit.test.ts
- src/http/api/preferences.controller.integration.test.ts
- src/http/api/helpers/post.unit.test.ts
ref TryGhost#1958 Preferences reads and updates should surface stale or invalid site IDs instead of treating them as a successful disabled sensitive-media preference. This also keeps the service aligned with the Result error contract and fixes the Biome import ordering reported by CI.
ref TryGhost#1958 Forked pull requests do not receive the OIDC token required to authenticate with GCP, so the image push and PR deployment jobs need to run only for same-repository PRs or non-PR events. This keeps fork PR CI focused on build and test checks instead of failing on unavailable credentials.
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/http/api/preferences.controller.integration.test.ts (1)
69-76: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winNew 500 tests won't catch the no-op-update false-positive.
These tests correctly assert a 500 for a genuinely nonexistent site, but they don't cover the case where
handleUpdatePreferencesis called twice with the same value for an existing user — which, per theaffectedRowssemantics issue inpreferences.repository.knex.ts, would spuriously also return 500. Consider adding a test that updates a preference to its current value and asserts it still succeeds (200), once the repository fix above lands.Also applies to: 118-128
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/api/preferences.controller.integration.test.ts` around lines 69 - 76, Add an integration test around `preferencesController.handleUpdatePreferences` that exercises an existing user updating a preference to the same value twice and asserts the second call still returns 200. This covers the `affectedRows` false-positive path in `preferences.repository.knex.ts` and prevents no-op updates from being treated like missing-user failures. Keep the existing 500 test for genuinely nonexistent sites, but extend the `preferences.controller.integration.test.ts` coverage near `handleGetPreferences`/`handleUpdatePreferences` to distinguish the two cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@src/http/api/preferences.controller.integration.test.ts`:
- Around line 69-76: Add an integration test around
`preferencesController.handleUpdatePreferences` that exercises an existing user
updating a preference to the same value twice and asserts the second call still
returns 200. This covers the `affectedRows` false-positive path in
`preferences.repository.knex.ts` and prevents no-op updates from being treated
like missing-user failures. Keep the existing 500 test for genuinely nonexistent
sites, but extend the `preferences.controller.integration.test.ts` coverage near
`handleGetPreferences`/`handleUpdatePreferences` to distinguish the two cases.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c5e32b4f-12fa-404e-9a52-02f4a13cfe9c
📒 Files selected for processing (4)
src/configuration/registrations.tssrc/http/api/preferences.controller.integration.test.tssrc/preferences/preferences.repository.knex.tssrc/preferences/preferences.service.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/configuration/registrations.ts
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/preferences/preferences.repository.knex.ts (1)
7-30: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftRepository throws exceptions instead of using the codebase's
Resulttype convention.Both
getForSiteandupdateForSitethrowPreferencesUserNotFoundErrordirectly. The repo's documented architecture requires error objects with context to be returned through aResulttype (consumed viaisError()/getValue()/getError()), not thrown as exceptions, so that callers get a typed, exhaustive error-handling path instead of relying on try/catch.While
PreferencesUserNotFoundErrorcorrectly carries context (siteId), it bypasses theResulttype wrapper this codebase standardizes on for fallible repository operations.As per coding guidelines, "Always use the Result type helper functions (
isError(),getError(),getValue()) rather than destructuring directly" and "Use error objects with context in Result types instead of string literal errors."Also applies to: 32-48
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/preferences/preferences.repository.knex.ts` around lines 7 - 30, `KnexPreferencesRepository.getForSite` (and the matching `updateForSite` path) is throwing `PreferencesUserNotFoundError` instead of following the repository `Result` convention. Change these methods to return a `Result` that wraps either the preferences value or a contextual error object, and make callers consume it with `isError()`, `getValue()`, and `getError()` rather than try/catch. Keep `PreferencesUserNotFoundError` as the context-carrying error type, but return it through the `Result` helper instead of throwing it directly.Source: Coding guidelines
🧹 Nitpick comments (1)
src/preferences/preferences.repository.knex.ts (1)
32-48: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winTOCTOU race between existence check and update.
updateForSiteselects to verify the user exists, then issues a separateupdate()without checking its result. If the row is deleted between the two queries, the method still returnspreferencesas if the write succeeded, even though nothing was persisted. Wrapping both queries in a transaction (optionally with.forUpdate()row locking) closes this gap.🛡️ Proposed fix using a transaction
async updateForSite( siteId: number, preferences: PreferencesDTO, ): Promise<PreferencesDTO> { - const userExists = await this.db('users') - .select('id') - .where({ site_id: siteId }) - .first(); - - if (!userExists) { - throw new PreferencesUserNotFoundError(siteId); - } - - await this.db('users').where({ site_id: siteId }).update({ - show_sensitive_media: preferences.showSensitiveMedia, - }); - - return preferences; + return this.db.transaction(async (trx) => { + const userExists = await trx('users') + .select('id') + .where({ site_id: siteId }) + .forUpdate() + .first(); + + if (!userExists) { + throw new PreferencesUserNotFoundError(siteId); + } + + await trx('users').where({ site_id: siteId }).update({ + show_sensitive_media: preferences.showSensitiveMedia, + }); + + return preferences; + }); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/preferences/preferences.repository.knex.ts` around lines 32 - 48, The `updateForSite` method in `PreferencesRepositoryKnex` has a TOCTOU gap because it checks for a user with a separate query and then performs `update()` without validating that the write actually affected a row. Fix this by moving the existence check and update into a single transaction, and consider locking the matched row with `.forUpdate()` on the initial lookup. After the `users` update, verify the affected row count and throw `PreferencesUserNotFoundError` if no row was updated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/preferences/preferences.repository.knex.ts`:
- Around line 7-30: `KnexPreferencesRepository.getForSite` (and the matching
`updateForSite` path) is throwing `PreferencesUserNotFoundError` instead of
following the repository `Result` convention. Change these methods to return a
`Result` that wraps either the preferences value or a contextual error object,
and make callers consume it with `isError()`, `getValue()`, and `getError()`
rather than try/catch. Keep `PreferencesUserNotFoundError` as the
context-carrying error type, but return it through the `Result` helper instead
of throwing it directly.
---
Nitpick comments:
In `@src/preferences/preferences.repository.knex.ts`:
- Around line 32-48: The `updateForSite` method in `PreferencesRepositoryKnex`
has a TOCTOU gap because it checks for a user with a separate query and then
performs `update()` without validating that the write actually affected a row.
Fix this by moving the existence check and update into a single transaction, and
consider locking the matched row with `.forUpdate()` on the initial lookup.
After the `users` update, verify the affected row count and throw
`PreferencesUserNotFoundError` if no row was updated.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3fe43d72-3b67-4fb2-93a2-5060f7dd7de0
📒 Files selected for processing (3)
.github/workflows/cicd.ymlsrc/http/api/preferences.controller.integration.test.tssrc/preferences/preferences.repository.knex.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/http/api/preferences.controller.integration.test.ts
Context
This is the ActivityPub backend side of the sensitive media and content warning work. It supports the Ghost admin UI changes that display sensitive-media warnings, content-warning disclosures, and the user preference for showing sensitive media by default.
TryGhost/Ghost#29054
The key distinction from the session is:
sensitiveis the persisted ActivityPub media/post flag used by the UI to decide whether media needs a sensitive warning.contentWarningis derived from remote sensitive posts with a summary and is treated as post-level disclosure by the UI.showSensitiveMediais a site/user preference used by the admin UI to decide whether sensitive media should be shown without the warning overlay.What changed
posts.sensitiveusers.show_sensitive_mediasensitivevalues for Notes and Articles.sensitiveflag changes.sensitiveand derivedcontentWarningfields in post DTOs.contentWarningonly for remote sensitive posts with a non-empty summary.GET/PUT /.ghost/activitypub/v1/preferencesAPI for theshowSensitiveMediasetting.progress.mddocumenting the implementation and audit notes from this work.Why
The Ghost admin needs enough backend information to render sensitive media and content warnings differently:
Validation
git diff --check origin/main...HEADorigin/mainfor the changed persistence, API, and DTO paths.pnpm test -- --run ...pass, butpnpmstopped before Vitest because dependency install hit ignored build scripts in the local checkout. No tracked source changes were left behind from that attempt.