Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 21 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ jobs:
permissions:
actions: read
contents: read
# Required by dorny/paths-filter, which calls pulls.listFiles on
# pull_request events. Private forks of this repo don't grant this
# implicitly when an explicit permissions block is present, so it must
# be listed here for the Setup job to succeed on those forks.
pull-requests: read
steps:
- name: Checkout current commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -825,6 +830,9 @@ jobs:
- run: mv ghost-*.tgz ghost.tgz
working-directory: ghost/core

- name: Verify packaged package.json
run: tar -xOf ghost/core/ghost.tgz package/package.json | jq -e '.packageManager' >/dev/null

- name: Save Ghost CLI Debug Logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand All @@ -835,13 +843,19 @@ jobs:
- name: Clean Install
run: |
DIR=$(mktemp -d)
ghost install local -d $DIR --archive $(pwd)/ghost/core/ghost.tgz
ghost install local -d "$DIR" --archive "$(pwd)/ghost/core/ghost.tgz"
URL=$(ghost config get url -d "$DIR" --no-prompt --no-color | tail -n1)
curl --retry 10 --retry-connrefused --retry-delay 3 -fsSI "$URL"
ghost stop -d "$DIR"

- name: Latest Release
run: |
DIR=$(mktemp -d)
ghost install local -d $DIR
ghost update -d $DIR --archive $(pwd)/ghost/core/ghost.tgz
ghost install local -d "$DIR"
ghost update -d "$DIR" --archive "$(pwd)/ghost/core/ghost.tgz"
URL=$(ghost config get url -d "$DIR" --no-prompt --no-color | tail -n1)
curl --retry 10 --retry-connrefused --retry-delay 3 -fsSI "$URL"
ghost stop -d "$DIR"

- name: Print debug logs
if: failure()
Expand Down Expand Up @@ -900,12 +914,8 @@ jobs:
exit 1
fi

- name: Pack standalone distribution
run: pnpm --filter ghost pack:standalone

- name: Create npm tarball
if: startsWith(github.ref, 'refs/tags/v')
run: pnpm --filter ghost pack:tarball
- name: Build standalone distribution
run: pnpm --filter ghost archive

- name: Upload npm tarball
if: startsWith(github.ref, 'refs/tags/v')
Expand Down Expand Up @@ -1724,7 +1734,7 @@ jobs:
# Publish Ghost npm package — runs on version tags only (OIDC, no stored token)
# --------------------------------------------------------------------------- #
publish_ghost:
needs: [job_setup, job_build_artifacts]
needs: [job_setup, job_build_artifacts, job_ghost-cli]
name: Publish Ghost to npm
runs-on: ubuntu-latest
if: |
Expand All @@ -1751,12 +1761,7 @@ jobs:
run: npm install -g npm@11

- name: Verify tarball contents
run: |
echo "Tarball contents:"
tar -tzf ghost-*.tgz | head -20
tar -tzf ghost-*.tgz | grep -q 'package/.npmrc' || { echo "::error::.npmrc not found in tarball"; exit 1; }
tar -tzf ghost-*.tgz | grep -q 'package/pnpm-lock.yaml' || { echo "::error::pnpm-lock.yaml not found in tarball"; exit 1; }
tar -xOf ghost-*.tgz package/package.json | jq -e '.packageManager' >/dev/null || { echo "::error::packageManager not found in packaged package.json"; exit 1; }
run: tar -xOf ghost-*.tgz package/package.json | jq -e '.packageManager' >/dev/null

- name: Publish to npm
run: npm publish ghost-*.tgz --access public
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ test/functional/*.png
/ghost/core/config.*.json
/ghost/core/config.*.jsonc

# Built asset files
# Build/deploy outputs
/ghost/core/package
/ghost/core/core/built
/ghost/core/core/frontend/public/ghost.min.css
/ghost/core/core/frontend/public/comment-counts.min.js
Expand Down
2 changes: 1 addition & 1 deletion e2e/playwright.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const getWorkerCount = () => {

/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
timeout: process.env.CI ? 30 * 1000 : 30 * 1000,
timeout: process.env.CI ? 60 * 1000 : 30 * 1000,
expect: {
timeout: process.env.CI ? 10 * 1000 : 10 * 1000
},
Expand Down
72 changes: 0 additions & 72 deletions ghost/core/.npmignore

This file was deleted.

1 change: 1 addition & 0 deletions ghost/core/core/frontend/helpers/prev_post.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const buildApiOptions = function buildApiOptions(options, post) {
include: 'author,authors,tags,tiers',
order: 'published_at ' + order,
limit: 1,
skipPagination: true,
// This line deliberately uses double quotes because GQL cannot handle either double quotes
// or escaped singles, see TryGhost/GQL#34
filter: "slug:-" + slug + "+published_at:" + op + "'" + publishedAt + "'", // eslint-disable-line quotes
Expand Down
4 changes: 3 additions & 1 deletion ghost/core/core/server/api/endpoints/posts-public.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const controller = {
'absolute_urls',
'collection'
]),
skipPagination: frame.options?.skipPagination === true,
auth: generateAuthData(frame),
method: 'browse'
};
Expand All @@ -84,7 +85,8 @@ const controller = {
'page',
'debug',
'absolute_urls',
'collection'
'collection',
'skipPagination'
],
validation: {
options: {
Expand Down
41 changes: 40 additions & 1 deletion ghost/core/core/server/models/base/plugins/crud.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,38 @@ const requiredForExcerpt = (requestedColumns) => {
}
};

const parsePositiveInteger = (value, defaultValue) => {
const parsedValue = Number.parseInt(value, 10);

if (Number.isNaN(parsedValue)) {
return defaultValue;
}

return Math.max(parsedValue, 1);
};

// Pre-applies limit/offset so callers can use fetchPage's limit='all'
// short-circuit (which skips the count query) while still fetching only
// the requested window.
const applyManualPaginationWindow = (itemCollection, options) => {
if (options.limit === 'all') {
return;
}

const limit = parsePositiveInteger(options.limit, 15);
const page = parsePositiveInteger(options.page, 1);

itemCollection
.query('limit', limit)
.query('offset', limit * (page - 1));

options.limit = 'all';
};

const buildPaginationMeta = (skipPagination, pagination) => {
return skipPagination ? {} : {pagination};
};

/**
* @param {Bookshelf} Bookshelf
*/
Expand Down Expand Up @@ -81,6 +113,9 @@ module.exports = function (Bookshelf) {
*/
findPage: async function findPage(unfilteredOptions) {
const options = this.filterOptions(unfilteredOptions, 'findPage');
const skipPagination = options.skipPagination === true;
delete options.skipPagination;

const itemCollection = this.getFilteredCollection(options);
const requestedColumns = options.columns;
// make sure we include plaintext and custom_excerpt if excerpt is requested
Expand Down Expand Up @@ -149,6 +184,10 @@ module.exports = function (Bookshelf) {
options.useSmartCount = true;
}

if (skipPagination) {
applyManualPaginationWindow(itemCollection, options);
}

const response = await itemCollection.fetchPage(options);
// Attributes are being filtered here, so they are not leaked into calling layer
// where models are serialized to json and do not do more filtering.
Expand All @@ -164,7 +203,7 @@ module.exports = function (Bookshelf) {

return {
data: data,
meta: {pagination: response.pagination}
meta: buildPaginationMeta(skipPagination, response.pagination)
};
},

Expand Down
2 changes: 1 addition & 1 deletion ghost/core/core/server/models/base/plugins/sanitize.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ module.exports = function (Bookshelf) {
case 'findAll':
return [...baseOptions, ...extraOptions, 'filter', 'columns', 'mongoTransformer'];
case 'findPage':
return [...baseOptions, ...extraOptions, 'filter', 'order', 'autoOrder', 'page', 'limit', 'columns', 'mongoTransformer'];
return [...baseOptions, ...extraOptions, 'filter', 'order', 'autoOrder', 'page', 'limit', 'columns', 'mongoTransformer', 'skipPagination'];
default:
return [...baseOptions, ...extraOptions];
}
Expand Down
22 changes: 16 additions & 6 deletions ghost/core/core/server/services/email-service/email-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ const messages = {
active: t('Your subscription will renew on {date}.'),
trial: t('Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.'),
complimentaryExpires: t('Your subscription will expire on {date}.'),
complimentaryInfinite: ''
complimentaryInfinite: '',
giftExpires: t('Your subscription will expire on {date}.')
}
};

Expand Down Expand Up @@ -117,10 +118,10 @@ function cheerioLoad(html) {
* @prop {string} uuid
* @prop {string} email
* @prop {string} name
* @prop {'free'|'paid'|'comped'} status
* @prop {'free'|'paid'|'comped'|'gift'} status
* @prop {Date|null} createdAt This can be null if the member has been deleted for older email recipient rows
* @prop {MemberLikeSubscription[]} subscriptions Required to get trial end / next renewal date / expire at date for paid member
* @prop {MemberLikeTier[]} tiers Required to get the expiry date in case of a comped member
* @prop {MemberLikeTier[]} tiers Required to get the expiry date in case of a comped or gift member
*
* @typedef {object} MemberLikeSubscription
* @prop {string} status
Expand Down Expand Up @@ -643,6 +644,16 @@ class EmailRenderer {
return t(messages.subscriptionStatus.free);
}

if (member.status === 'gift') {
const expires = member.tiers[0]?.expiry_at ?? null;
if (expires) {
const timezone = this.#settingsCache.get('timezone');
const date = formatDateLong(expires, timezone, locale);
return t(messages.subscriptionStatus.giftExpires, {date});
}
return '';
}

// Do we have an active subscription?
if (member.status === 'paid') {
let activeSubscription = member.subscriptions.find((subscription) => {
Expand Down Expand Up @@ -1031,8 +1042,8 @@ class EmailRenderer {
}

const postUrl = this.#getPostUrl(post);
const isPublicPost = post.get('visibility') === 'public';
const showShareButton = isPublicPost && newsletter.get('show_share_button');
const hasEmailOnlyFlag = post.related('posts_meta')?.get('email_only') ?? false;
const showShareButton = newsletter.get('show_share_button') && !hasEmailOnlyFlag;
const shareUrl = new URL(postUrl);
shareUrl.hash = '/share';

Expand All @@ -1057,7 +1068,6 @@ class EmailRenderer {
const commentUrl = new URL(postUrl);
commentUrl.hash = '#ghost-comments-root';

const hasEmailOnlyFlag = post.related('posts_meta')?.get('email_only') ?? false;
const hasFeedbackButtons = newsletter.get('feedback_enabled');
const showCommentCta = newsletter.get('show_comment_cta') && this.#settingsCache.get('comments_enabled') !== 'off' && !hasEmailOnlyFlag;
const feedbackButtonCount = (hasFeedbackButtons ? 2 : 0) + (showCommentCta ? 1 : 0) + (showShareButton ? 1 : 0);
Expand Down
16 changes: 16 additions & 0 deletions ghost/core/core/server/services/gifts/gift-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import errors from '@tryghost/errors';
import logging from '@tryghost/logging';
import {Gift} from './gift';
Expand Down Expand Up @@ -124,6 +125,21 @@ export class GiftService {
this.deps = deps;
}

generateToken(): string {
/**
* Combinations: 62^12 ≈ 3.23 × 10^21 (~3.23 sextillion)
* Entropy: 12 × log2(62) ≈ 71.45 bits
*/
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = '';

for (let i = 0; i < 12; i++) {
token += alphabet[crypto.randomInt(alphabet.length)];
}

return token;
}

async recordPurchase(data: GiftPurchaseData): Promise<boolean> {
const duration = Number.parseInt(data.duration);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ module.exports = function MembersAPI({
Offer,
offersAPI,
stripeAPIService,
settingsCache
settingsCache,
giftService
});

const memberController = new MemberController({
Expand Down
Loading
Loading