Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1a93b0d
Removed NiceModal from settings and Shade (#30017)
9larsons Aug 18, 2026
7854a6c
Changed settings save callbacks to accept async handlers (#30076)
9larsons Aug 18, 2026
85478e3
Expanded email analytics service test coverage (#30073)
EvanHahn Aug 18, 2026
2d7d3a2
Moved general settings out of the settings quarantine (#30080)
9larsons Aug 18, 2026
62b84e4
Added Oxfmt format scripts (#30079)
9larsons Aug 18, 2026
d1aa1cb
Changed ESLint config and inline directives to survive the Oxfmt cuto…
9larsons Aug 18, 2026
3b1a39f
Fixed flaky comments pagination acceptance test (#30083)
9larsons Aug 18, 2026
fc70961
Improved automation canvas recentering after deletion (#30075)
troyciesco Aug 18, 2026
0fd6dee
Disabled GCHR cache push on PRs (#30081)
acburdine Aug 18, 2026
30f7b2b
Changed Renovate automerge to rebase instead of squash (#30088)
acburdine Aug 18, 2026
7ae798c
Changed the Koenig font install to fail fast on a stuck apt fetch (#3…
acburdine Aug 18, 2026
15f6da9
Add ActivityPub sensitive media controls (#29054)
vitrixbot Aug 18, 2026
33f6d32
Added "in progress run count" stat to automation browse endpoint (#30…
EvanHahn Aug 18, 2026
4300b4c
Added acceptance tests for field picker selection and search
rob-ghost Aug 18, 2026
1b36bbf
Changed import mapping targets to describe themselves to the field pi…
rob-ghost Aug 18, 2026
d08d3a4
Moved membership settings out of the settings quarantine (#30082)
9larsons Aug 18, 2026
7c0ae35
Stopped exporting unused utility from `models` (#30092)
EvanHahn Aug 18, 2026
3d891aa
Fixed small type error in email analytics job functions (#30089)
EvanHahn Aug 18, 2026
af90b48
Fixed flaky comments thread navigation acceptance test (#30094)
9larsons Aug 18, 2026
d64be44
Fixed Mailgun tags for automation emails (#30093)
cmraible Aug 18, 2026
bc70a70
Moved growth settings out of the settings quarantine (#30084)
9larsons Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/easy-wasps-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@tryghost/kg-utils": none
"@tryghost/kg-unsplash-selector": none
"@tryghost/kg-markdown-html-renderer": none
"@tryghost/kg-html-to-lexical": none
"@tryghost/kg-default-transforms": none
"@tryghost/kg-default-cards": none
"@tryghost/kg-converters": none
"@tryghost/kg-clean-basic-html": none
"@tryghost/kg-card-factory": none
---

Switched lint config to formatter-neutral rules; no runtime change
7 changes: 7 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
// We have to disable platform based automerge (forcing renovate to do it manually)
// as otherwise renovate wont follow our schedule
"platformAutomerge": false,
// Merge automerged PRs by rebasing the branch's own commits onto the base
// instead of squashing. Renovate branches are a single, concise commit
// (e.g. "chore(deps): update X to Y"), so a squash merge would replace that
// clean message with the PR title *and* the full PR body — release-note
// tables, changelogs, dependency dashboards — producing a giant commit
// message on main. Rebase preserves the branch commit message verbatim.
"automergeStrategy": "rebase",
// Branch protection does not require up-to-date branches (the required
// status checks ruleset has strict: false), so a green-but-behind branch
// is still mergeable. Rebasing on every `main` commit therefore buys
Expand Down
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1039,15 +1039,30 @@ jobs:
# which depends on Arial's font metrics
# - playwright firefox deps: system media codecs so firefox can decode
# the H.264 fixtures in the video card tests
# msttcorefonts downloads the font files from SourceForge at install time;
# mirror-redirect roulette there can hang indefinitely. timeout-minutes is
# the backstop, and each network op is bounded + retried so a stuck fetch
# fails fast and re-rolls onto a different mirror instead of sitting.
- name: Install Koenig editor test dependencies (fonts + media codecs)
if: matrix.app == '@tryghost/koenig-lexical'
timeout-minutes: 10
env:
DEBIAN_FRONTEND: noninteractive
DEBCONF_NONINTERACTIVE_SEEN: "true"
run: |
sudo sh -c "echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections"
sudo apt-get update -yq
sudo apt-get install -yq msttcorefonts
sudo apt-get update -yq -o Acquire::Retries=3 -o Acquire::http::Timeout=30
installed=
for i in 1 2 3; do
if sudo timeout -k 5 120 apt-get install -yq \
-o Acquire::Retries=3 -o Acquire::http::Timeout=30 msttcorefonts; then
installed=1; break
fi
echo "msttcorefonts attempt $i failed/stuck, retrying..."; sleep 5
done
if [ -z "$installed" ]; then
echo "msttcorefonts failed after 3 attempts"; exit 1
fi
pnpm exec playwright install-deps firefox

- name: Run Playwright tests
Expand Down Expand Up @@ -1464,7 +1479,11 @@ jobs:
tags: ${{ steps.meta-core.outputs.tags }}
labels: ${{ steps.meta-core.outputs.labels }}
cache-from: type=registry,ref=${{ steps.strategy.outputs.image-core-name }}:cache-main
cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-core-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }}
# PRs read cache-main but never write cache: the old cache-pr-N was
# write-only (cache-from is always cache-main) and mode=max cache export
# is the heaviest GHCR push in the run — skipping it on PRs cuts push
# volume with no rebuild cost. Only main/tag publish cache-main.
cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-core-name) || '' }}

# Uploaded here, before the full image is even built: on the artifact path
# consumers (Ghost-Moya CD) need the core image — server only, no admin —
Expand Down Expand Up @@ -1535,7 +1554,8 @@ jobs:
tags: ${{ steps.meta-full.outputs.tags }}
labels: ${{ steps.meta-full.outputs.labels }}
cache-from: type=registry,ref=${{ steps.strategy.outputs.image-full-name }}:cache-main
cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-full-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }}
# See core image above: PRs skip cache-to; only main/tag publish cache-main.
cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-full-name) || '' }}

# The production image artifact is saved before the e2e steps below:
# Ghost-Moya CD discovers `docker-image-production` by name in this run
Expand Down Expand Up @@ -1711,7 +1731,8 @@ jobs:
tags: ${{ steps.meta-e2e.outputs.tags }}
labels: ${{ steps.meta-e2e.outputs.labels }}
cache-from: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-main', steps.strategy.outputs.image-e2e-name) || '' }}
cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-e2e-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }}
# See core image above: PRs skip cache-to; only main/tag publish cache-main.
cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-e2e-name) || '' }}

- name: Save E2E image as artifact
if: steps.strategy.outputs.use-artifact == 'true'
Expand Down
181 changes: 179 additions & 2 deletions apps/activitypub/src/api/activitypub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,153 @@ describe('ActivityPubAPI', function () {
});
});

describe('getPreferences', function () {
test('It returns the sensitive media display preference', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/v1/preferences': {
async assert(_resource, init) {
const headers = new Headers(init?.headers);
expect(init?.method).toEqual('GET');
expect(headers.get('Authorization')).toContain('fake-token');
},
response: JSONResponse({
showSensitiveMedia: true
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);

await expect(api.getPreferences()).resolves.toEqual({
showSensitiveMedia: true
});
});

test('It defaults showSensitiveMedia to false when it is missing', async function () {
const fakeFetch = Fetch({
'https://activitypub.api/.ghost/activitypub/v1/preferences': {
response: JSONResponse({})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);

await expect(api.getPreferences()).resolves.toEqual({
showSensitiveMedia: false
});
});
});

describe('updatePreferences', function () {
test('It saves the sensitive media display preference', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/v1/preferences': {
async assert(_resource, init) {
const headers = new Headers(init?.headers);
expect(init?.method).toEqual('PUT');
expect(headers.get('Authorization')).toContain('fake-token');
expect(headers.get('Content-Type')).toEqual('application/json');
expect(init?.body).toEqual(JSON.stringify({
showSensitiveMedia: true
}));
},
response: JSONResponse({
showSensitiveMedia: true
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);

await expect(api.updatePreferences({showSensitiveMedia: true})).resolves.toEqual({
showSensitiveMedia: true
});
});

test('It can disable the sensitive media display preference', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/v1/preferences': {
async assert(_resource, init) {
expect(init?.method).toEqual('PUT');
expect(init?.body).toEqual(JSON.stringify({
showSensitiveMedia: false
}));
},
response: JSONResponse({
showSensitiveMedia: false
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);

await expect(api.updatePreferences({showSensitiveMedia: false})).resolves.toEqual({
showSensitiveMedia: false
});
});

test('It preserves the submitted preference when the update response has no body', async function () {
const fakeFetch = Fetch({
'https://activitypub.api/.ghost/activitypub/v1/preferences': {
async assert(_resource, init) {
expect(init?.method).toEqual('PUT');
expect(init?.body).toEqual(JSON.stringify({
showSensitiveMedia: true
}));
},
response: new Response(null, {status: 204})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);

await expect(api.updatePreferences({showSensitiveMedia: true})).resolves.toEqual({
showSensitiveMedia: true
});
});
});

describe('follow', function () {
test('It passes the token to the follow endpoint', async function () {
const fakeFetch = Fetch({
Expand Down Expand Up @@ -1356,7 +1503,22 @@ describe('ActivityPubAPI', function () {
notifications: [
{
id: 'https://example.com/notifications/abc123',
type: 'like'
type: 'like',
post: {
id: 'https://example.com/posts/sensitive',
type: 'note',
title: null,
content: '<p>Sensitive post</p>',
url: 'https://example.com/posts/sensitive',
sensitive: true,
contentWarning: 'Sensitive topic',
likeCount: 0,
likedByMe: false,
repostCount: 0,
repostedByMe: false,
replyCount: 0,
attachments: []
}
},
{
id: 'https://example.com/notifications/def456',
Expand All @@ -1380,7 +1542,22 @@ describe('ActivityPubAPI', function () {
expect(actual.notifications).toEqual([
{
id: 'https://example.com/notifications/abc123',
type: 'like'
type: 'like',
post: {
id: 'https://example.com/posts/sensitive',
type: 'note',
title: null,
content: '<p>Sensitive post</p>',
url: 'https://example.com/posts/sensitive',
sensitive: true,
contentWarning: 'Sensitive topic',
likeCount: 0,
likedByMe: false,
repostCount: 0,
repostedByMe: false,
replyCount: 0,
attachments: []
}
},
{
id: 'https://example.com/notifications/def456',
Expand Down
43 changes: 43 additions & 0 deletions apps/activitypub/src/api/activitypub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export interface Notification {
title: string | null;
content: string;
url: string;
sensitive?: boolean;
contentWarning?: string | null;
likeCount: number;
likedByMe: boolean;
repostCount: number;
Expand All @@ -177,6 +179,8 @@ export interface Notification {
title: string | null;
content: string;
url: string;
sensitive?: boolean;
contentWarning?: string | null;
},
createdAt: string;
}
Expand Down Expand Up @@ -206,6 +210,23 @@ export interface SocialWebDomain {
actorUrl: string;
}

export interface Preferences {
showSensitiveMedia: boolean;
}

/**
* Fails closed: anything the server doesn't explicitly confirm leaves sensitive
* media hidden.
*/
function parsePreferences(json: object | null): Preferences {
return {
showSensitiveMedia:
json !== null &&
'showSensitiveMedia' in json &&
json.showSensitiveMedia === true
};
}

export const PostType = {
Note: 0,
Article: 1,
Expand All @@ -220,6 +241,8 @@ export interface Post {
title: string;
excerpt: string;
summary: string | null;
sensitive?: boolean;
contentWarning?: string | null;
content: string;
url: string;
featureImageUrl: string | null;
Expand Down Expand Up @@ -684,6 +707,26 @@ export class ActivityPubAPI {
return {count};
}

async getPreferences(): Promise<Preferences> {
const url = new URL('.ghost/activitypub/v1/preferences', this.apiUrl);

return parsePreferences(await this.fetchJSON(url));
}

async updatePreferences(preferences: Preferences): Promise<Preferences> {
const url = new URL('.ghost/activitypub/v1/preferences', this.apiUrl);
const json = await this.fetchJSON(url, 'PUT', preferences);

// A body-less success means the write was accepted but not echoed back,
// so the submitted value is what's now stored. Parsing null here would
// silently report the preference as disabled.
if (json === null) {
return preferences;
}

return parsePreferences(json);
}

async resetNotificationsCount() {
const url = new URL('.ghost/activitypub/v1/notifications/unread/reset', this.apiUrl);

Expand Down
Loading
Loading