diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index e9c003ae6a..9526218b2b 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -41,7 +41,7 @@ const COMMAND_ADMIN = { const COMMAND_TYPESCRIPT = { name: 'ts', - command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations -- nx run \\$NX_PROJECT_NAME:build:ts', + command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations -- nx run \\$NX_PROJECT_NAME:build:ts', cwd: path.resolve(__dirname, '../../'), prefixColor: 'cyan', env: {} diff --git a/ghost/bookshelf-repository/.eslintrc.js b/ghost/bookshelf-repository/.eslintrc.js new file mode 100644 index 0000000000..cb690be63f --- /dev/null +++ b/ghost/bookshelf-repository/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/ts' + ] +}; diff --git a/ghost/bookshelf-repository/README.md b/ghost/bookshelf-repository/README.md new file mode 100644 index 0000000000..c63e633c50 --- /dev/null +++ b/ghost/bookshelf-repository/README.md @@ -0,0 +1,21 @@ +# Bookshelf Repository + + +## Usage + + +## Develop + +This is a monorepo package. + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + diff --git a/ghost/bookshelf-repository/package.json b/ghost/bookshelf-repository/package.json new file mode 100644 index 0000000000..d9eb30b634 --- /dev/null +++ b/ghost/bookshelf-repository/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tryghost/bookshelf-repository", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost/tree/main/ghost/bookshelf-repository", + "author": "Ghost Foundation", + "private": true, + "main": "build/index.js", + "types": "build/index.d.ts", + "scripts": { + "build": "tsc", + "build:ts": "yarn build", + "test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'", + "test": "yarn test:types && yarn test:unit", + "test:types": "tsc --noEmit", + "lint:code": "eslint src/ --ext .ts --cache", + "lint": "yarn lint:code && yarn lint:test", + "lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache" + }, + "files": [ + "build" + ], + "devDependencies": { + "c8": "7.14.0", + "mocha": "10.2.0", + "sinon": "15.2.0" + }, + "dependencies": { + "@tryghost/nql": "0.11.0" + } +} diff --git a/ghost/bookshelf-repository/src/BookshelfRepository.ts b/ghost/bookshelf-repository/src/BookshelfRepository.ts new file mode 100644 index 0000000000..9ac1712b7a --- /dev/null +++ b/ghost/bookshelf-repository/src/BookshelfRepository.ts @@ -0,0 +1,62 @@ +type Entity = { + id: T; + deleted: boolean; +} + +type Order = { + field: keyof T; + direction: 'asc' | 'desc'; +} + +export type ModelClass = { + destroy: (data: {id: T}) => Promise; + findOne: (data: {id: T}, options?: {require?: boolean}) => Promise | null>; + findAll: (options: {filter?: string; order?: OrderOption}) => Promise[]>; + add: (data: object) => Promise>; +} + +export type ModelInstance = { + id: T; + get(field: string): unknown; + set(data: object|string, value?: unknown): void; + save(properties: object, options?: {autoRefresh?: boolean; method?: 'update' | 'insert'}): Promise>; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OrderOption = any> = Order[]; + +export abstract class BookshelfRepository> { + protected Model: ModelClass; + + constructor(Model: ModelClass) { + this.Model = Model; + } + + protected abstract toPrimitive(entity: T): object; + protected abstract modelToEntity(model: ModelInstance): Promise | T | null; + + async save(entity: T): Promise { + if (entity.deleted) { + await this.Model.destroy({id: entity.id}); + return; + } + + const existing = await this.Model.findOne({id: entity.id}, {require: false}); + if (existing) { + existing.set(this.toPrimitive(entity)) + await existing.save({}, {autoRefresh: false, method: 'update'}); + } else { + await this.Model.add(this.toPrimitive(entity)) + } + } + + async getById(id: IDType): Promise { + const model = await this.Model.findOne({id}, {require: false}) as ModelInstance | null; + return model ? this.modelToEntity(model) : null; + } + + async getAll({filter, order}: { filter?: string; order?: OrderOption } = {}): Promise { + const models = await this.Model.findAll({filter, order}) as ModelInstance[]; + return (await Promise.all(models.map(model => this.modelToEntity(model)))).filter(entity => !!entity) as T[]; + } +} diff --git a/ghost/bookshelf-repository/src/index.ts b/ghost/bookshelf-repository/src/index.ts new file mode 100644 index 0000000000..2458798717 --- /dev/null +++ b/ghost/bookshelf-repository/src/index.ts @@ -0,0 +1 @@ +export * from './BookshelfRepository'; diff --git a/ghost/bookshelf-repository/test/.eslintrc.js b/ghost/bookshelf-repository/test/.eslintrc.js new file mode 100644 index 0000000000..6fe6dc1504 --- /dev/null +++ b/ghost/bookshelf-repository/test/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/bookshelf-repository/test/BookshelfRepository.test.ts b/ghost/bookshelf-repository/test/BookshelfRepository.test.ts new file mode 100644 index 0000000000..8758f699bc --- /dev/null +++ b/ghost/bookshelf-repository/test/BookshelfRepository.test.ts @@ -0,0 +1,202 @@ +import assert from 'assert'; +import {BookshelfRepository, ModelClass, ModelInstance} from '../src/index'; + +type SimpleEntity = { + id: string; + deleted: boolean; + name: string; + age: number; + birthday: string; +} + +class SimpleBookshelfRepository extends BookshelfRepository { + protected modelToEntity(model: ModelInstance): SimpleEntity { + return { + id: model.id, + deleted: false, + name: model.get('name') as string, + age: model.get('age') as number, + birthday: model.get('birthday') as string + }; + } + + protected toPrimitive(entity: SimpleEntity): object { + return { + id: entity.id, + name: entity.name, + age: entity.age, + birthday: entity.birthday + }; + } +} + +class Model implements ModelClass { + items: ModelInstance[] = []; + + constructor() { + this.items = []; + } + + destroy(data: {id: string;}): Promise { + this.items = this.items.filter(item => item.id !== data.id); + return Promise.resolve(); + } + + findOne(data: {id: string;}, options?: {require?: boolean | undefined;} | undefined): Promise | null> { + const item = this.items.find(i => i.id === data.id); + if (!item && options?.require) { + throw new Error('Not found'); + } + return Promise.resolve(item ?? null); + } + findAll(options: {filter?: string | undefined; order?: {field: string | number | symbol; direction: 'desc' | 'asc';}[] | undefined;}): Promise[]> { + const sorted = this.items.slice().sort((a, b) => { + for (const order of options.order ?? []) { + const aValue = a.get(order.field as string) as number; + const bValue = b.get(order.field as string) as number; + if (aValue < bValue) { + return order.direction === 'asc' ? -1 : 1; + } else if (aValue > bValue) { + return order.direction === 'asc' ? 1 : -1; + } + } + return 0; + }); + return Promise.resolve(sorted); + } + + add(data: object): Promise> { + const item = { + id: (data as any).id, + get(field: string): unknown { + return (data as any)[field]; + }, + set(d: object|string, value?: unknown): void { + if (typeof d === 'string') { + (data as any)[d] = value; + } else { + Object.assign(data, d); + } + }, + save(properties: object): Promise> { + Object.assign(data, properties); + return Promise.resolve(item); + } + }; + this.items.push(item); + return Promise.resolve(item); + } +} + +describe('BookshelfRepository', function () { + it('Can save, retrieve, update and delete entities', async function () { + const repository = new SimpleBookshelfRepository(new Model()); + + checkRetrieving: { + const entity = { + id: '1', + deleted: false, + name: 'John', + age: 30, + birthday: new Date('2000-01-01').toISOString() + }; + + await repository.save(entity); + const result = await repository.getById('1'); + + assert(result); + assert(result.name === 'John'); + assert(result.age === 30); + assert(result.id === '1'); + + break checkRetrieving; + } + + checkUpdating: { + const entity = { + id: '2', + deleted: false, + name: 'John', + age: 24, + birthday: new Date('2000-01-01').toISOString() + }; + + await repository.save(entity); + + entity.name = 'Kym'; + + await repository.save(entity); + + const result = await repository.getById('2'); + + assert(result); + assert.equal(result.name, 'Kym'); + assert.equal(result.age, 24); + assert.equal(result.id, '2'); + + break checkUpdating; + } + + checkDeleting: { + const entity = { + id: '3', + deleted: false, + name: 'Egg', + age: 180, + birthday: new Date('2010-01-01').toISOString() + }; + + await repository.save(entity); + + assert(await repository.getById('3')); + + entity.deleted = true; + + await repository.save(entity); + + assert(!await repository.getById('3')); + + break checkDeleting; + } + }); + + it('Can save and retrieve all entities', async function () { + const repository = new SimpleBookshelfRepository(new Model()); + const entities = [{ + id: '1', + deleted: false, + name: 'Kym', + age: 24, + birthday: new Date('2000-01-01').toISOString() + }, { + id: '2', + deleted: false, + name: 'John', + age: 30, + birthday: new Date('2000-01-01').toISOString() + }, { + id: '3', + deleted: false, + name: 'Kevin', + age: 5, + birthday: new Date('2000-01-01').toISOString() + }]; + + for (const entity of entities) { + await repository.save(entity); + } + + const result = await repository.getAll({ + order: [{ + field: 'age', + direction: 'desc' + }] + }); + + assert(result); + assert(result.length === 3); + assert(result[0].age === 30); + assert(result[1].age === 24); + assert(result[2].age === 5); + }); +}); diff --git a/ghost/bookshelf-repository/tsconfig.json b/ghost/bookshelf-repository/tsconfig.json new file mode 100644 index 0000000000..7f7ed38664 --- /dev/null +++ b/ghost/bookshelf-repository/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "src/**/*" + ], + "compilerOptions": { + "outDir": "build" + } +} diff --git a/ghost/core/core/server/models/recommendation.js b/ghost/core/core/server/models/recommendation.js new file mode 100644 index 0000000000..3e90bb9d91 --- /dev/null +++ b/ghost/core/core/server/models/recommendation.js @@ -0,0 +1,10 @@ +const ghostBookshelf = require('./base'); + +const Recommendation = ghostBookshelf.Model.extend({ + tableName: 'recommendations', + defaults: {} +}, {}); + +module.exports = { + Recommendation: ghostBookshelf.model('Recommendation', Recommendation) +}; diff --git a/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js b/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js index cbf1c86d55..cee6ae13c8 100644 --- a/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js +++ b/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js @@ -21,7 +21,9 @@ class RecommendationServiceWrapper { const config = require('../../../shared/config'); const urlUtils = require('../../../shared/url-utils'); - const {InMemoryRecommendationRepository, RecommendationService, RecommendationController, WellknownService} = require('@tryghost/recommendations'); + const models = require('../../models'); + const sentry = require('../../../shared/sentry'); + const {BookshelfRecommendationRepository, RecommendationService, RecommendationController, WellknownService} = require('@tryghost/recommendations'); const mentions = require('../mentions'); @@ -35,7 +37,9 @@ class RecommendationServiceWrapper { urlUtils }); - this.repository = new InMemoryRecommendationRepository(); + this.repository = new BookshelfRecommendationRepository(models.Recommendation, { + sentry + }); this.service = new RecommendationService({ repository: this.repository, wellknownService, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap index 49a7001ac3..d9414bb7b5 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap @@ -112,7 +112,7 @@ Object { "one_click_subscribe": true, "reason": "Because dogs are cute", "title": "Dog Pictures", - "updated_at": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "url": "https://dogpictures.com/", }, ], @@ -123,7 +123,7 @@ exports[`Recommendations Admin API Can browse 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "354", + "content-length": "376", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -178,3 +178,65 @@ Object { "x-powered-by": "Express", } `; + +exports[`Recommendations Admin API Cannot edit to invalid recommendation state 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "Featured image must be a valid URL", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit recommendation.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Recommendations Admin API Cannot edit to invalid recommendation state 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "265", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API Cannot use invalid protocols when editing 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "Featured image must be a valid URL", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit recommendation.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Recommendations Admin API Cannot use invalid protocols when editing 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "265", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/recommendations.test.js b/ghost/core/test/e2e-api/admin/recommendations.test.js index d533ca9010..5d05e40829 100644 --- a/ghost/core/test/e2e-api/admin/recommendations.test.js +++ b/ghost/core/test/e2e-api/admin/recommendations.test.js @@ -1,5 +1,5 @@ const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework'); -const {anyObjectId, anyISODateTime, anyContentVersion, anyLocationFor, anyEtag} = matchers; +const {anyObjectId, anyErrorId, anyISODateTime, anyContentVersion, anyLocationFor, anyEtag} = matchers; const assert = require('assert/strict'); const recommendationsService = require('../../../core/server/services/recommendations'); @@ -133,6 +133,34 @@ describe('Recommendations Admin API', function () { assert.equal(body.recommendations[0].one_click_subscribe, false); }); + it('Cannot use invalid protocols when editing', async function () { + const id = (await recommendationsService.repository.getAll())[0].id; + await agent.put(`recommendations/${id}/`) + .body({ + recommendations: [{ + title: 'Cat Pictures', + url: 'https://catpictures.com', + reason: 'Because cats are cute', + excerpt: 'Cats are cute', + featured_image: 'ftp://catpictures.com/cat.jpg', + favicon: 'ftp://catpictures.com/favicon.ico', + one_click_subscribe: false + }] + }) + .expectStatus(422) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + errors: [ + { + id: anyErrorId + } + ] + }); + }); + it('Can delete recommendation', async function () { const id = (await recommendationsService.repository.getAll())[0].id; await agent.delete(`recommendations/${id}/`) @@ -155,7 +183,8 @@ describe('Recommendations Admin API', function () { recommendations: [ { id: anyObjectId, - created_at: anyISODateTime + created_at: anyISODateTime, + updated_at: anyISODateTime } ] }); diff --git a/ghost/recommendations/package.json b/ghost/recommendations/package.json index 318fe058cc..e14e69162a 100644 --- a/ghost/recommendations/package.json +++ b/ghost/recommendations/package.json @@ -31,6 +31,9 @@ }, "dependencies": { "@tryghost/tpl": "0.1.25", - "@tryghost/errors": "1.2.25" + "@tryghost/errors": "1.2.25", + "@tryghost/in-memory-repository": "0.0.0", + "@tryghost/bookshelf-repository": "0.0.0", + "@tryghost/logging": "2.4.5" } } diff --git a/ghost/recommendations/src/BookshelfRecommendationRepository.ts b/ghost/recommendations/src/BookshelfRecommendationRepository.ts new file mode 100644 index 0000000000..a00737c2c3 --- /dev/null +++ b/ghost/recommendations/src/BookshelfRecommendationRepository.ts @@ -0,0 +1,53 @@ +import {Recommendation} from "./Recommendation"; +import {RecommendationRepository} from "./RecommendationRepository"; +import {BookshelfRepository, ModelClass, ModelInstance} from '@tryghost/bookshelf-repository'; +import logger from '@tryghost/logging'; + +type Sentry = { + captureException(err: unknown): void; +} + +export class BookshelfRecommendationRepository extends BookshelfRepository implements RecommendationRepository { + sentry?: Sentry; + + constructor(Model: ModelClass, deps: {sentry?: Sentry} = {}) { + super(Model); + this.sentry = deps.sentry; + } + + toPrimitive(entity: Recommendation): object { + return { + id: entity.id, + title: entity.title, + reason: entity.reason, + excerpt: entity.excerpt, + featured_image: entity.featuredImage?.toString(), + favicon: entity.favicon?.toString(), + url: entity.url.toString(), + one_click_subscribe: entity.oneClickSubscribe, + created_at: entity.createdAt, + updated_at: entity.updatedAt, + } + } + + modelToEntity(model: ModelInstance): Recommendation | null { + try { + return Recommendation.create({ + id: model.id, + title: model.get('title') as string, + reason: model.get('reason') as string | null, + excerpt: model.get('excerpt') as string | null, + featuredImage: (model.get('featured_image') as string | null) !== null ? new URL(model.get('featured_image') as string) : null, + favicon: (model.get('favicon') as string | null) !== null ? new URL(model.get('favicon') as string) : null, + url: new URL(model.get('url') as string), + oneClickSubscribe: model.get('one_click_subscribe') as boolean, + createdAt: model.get('created_at') as Date, + updatedAt: model.get('updated_at') as Date | null, + }) + } catch (err) { + logger.error(err); + this.sentry?.captureException(err); + return null; + } + } +} diff --git a/ghost/recommendations/src/InMemoryRecommendationRepository.ts b/ghost/recommendations/src/InMemoryRecommendationRepository.ts index 8c473080fc..0e3e07a008 100644 --- a/ghost/recommendations/src/InMemoryRecommendationRepository.ts +++ b/ghost/recommendations/src/InMemoryRecommendationRepository.ts @@ -3,54 +3,6 @@ import {RecommendationRepository} from "./RecommendationRepository"; import {InMemoryRepository} from '@tryghost/in-memory-repository'; export class InMemoryRecommendationRepository extends InMemoryRepository implements RecommendationRepository { - store: Recommendation[] = [ - new Recommendation({ - title: "She‘s A Beast", - reason: "She helped me get back into the gym after 8 years of chilling", - excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.", - featuredImage: "https://www.thepragmaticprogrammer.com/image.png", - favicon: "https://www.shesabeast.co/content/images/size/w256h256/2022/08/transparent-icon-black-copy-gray-bar.png", - url: new URL("https://www.thepragmaticprogrammer.com/"), - oneClickSubscribe: false - }), - new Recommendation({ - title: "Lenny‘s Newsletter", - reason: "He knows his stuff about product management and gives away lots of content for free. Highly recommended!", - excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.", - featuredImage: "https://www.thepragmaticprogrammer.com/image.png", - favicon: "https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7cde267-8f9e-47fa-9aef-5be03bad95ed%2Fapple-touch-icon-1024x1024.png", - url: new URL("https://www.thepragmaticprogrammer.com/"), - oneClickSubscribe: false - }), - new Recommendation({ - title: "Clickhole", - reason: "Funny", - excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.", - featuredImage: "https://www.thepragmaticprogrammer.com/image.png", - favicon: "https://clickhole.com/wp-content/uploads/2020/05/cropped-clickhole-icon-180x180.png", - url: new URL("https://www.thepragmaticprogrammer.com/"), - oneClickSubscribe: false - }), - new Recommendation({ - title: "The Verge", - reason: "Consistently best tech news, I read it every day!", - excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.", - featuredImage: "https://www.thepragmaticprogrammer.com/image.png", - favicon: "https://www.theverge.com/icons/apple_touch_icon.png", - url: new URL("https://www.thepragmaticprogrammer.com/"), - oneClickSubscribe: false - }), - new Recommendation({ - title: "The Counteroffensive with Tim Mak", - reason: "On-the-ground war reporting from Ukraine.", - excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.", - featuredImage: "https://www.thepragmaticprogrammer.com/image.png", - favicon: "https://substackcdn.com/image/fetch/w_96,h_96,c_fill,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3f2b2ad-681f-45e1-9496-db80f45e853d_403x403.png", - url: new URL("https://www.thepragmaticprogrammer.com/"), - oneClickSubscribe: true - }) - ]; - toPrimitive(entity: Recommendation): object { return entity; } diff --git a/ghost/recommendations/src/Recommendation.ts b/ghost/recommendations/src/Recommendation.ts index 512ae78549..bddf034092 100644 --- a/ghost/recommendations/src/Recommendation.ts +++ b/ghost/recommendations/src/Recommendation.ts @@ -1,24 +1,27 @@ import ObjectId from "bson-objectid"; +import errors from "@tryghost/errors"; export type AddRecommendation = { title: string reason: string|null excerpt: string|null // Fetched from the site meta data - featuredImage: string|null // Fetched from the site meta data - favicon: string|null // Fetched from the site meta data + featuredImage: URL|null // Fetched from the site meta data + favicon: URL|null // Fetched from the site meta data url: URL oneClickSubscribe: boolean } export type EditRecommendation = Partial +type RecommendationConstructorData = AddRecommendation & {id: string, createdAt: Date, updatedAt: Date|null} +export type RecommendationCreateData = AddRecommendation & {id?: string, createdAt?: Date, updatedAt?: Date|null} export class Recommendation { id: string title: string reason: string|null excerpt: string|null // Fetched from the site meta data - featuredImage: string|null // Fetched from the site meta data - favicon: string|null // Fetched from the site meta data + featuredImage: URL|null // Fetched from the site meta data + favicon: URL|null // Fetched from the site meta data url: URL oneClickSubscribe: boolean createdAt: Date @@ -30,8 +33,8 @@ export class Recommendation { return this.#deleted; } - constructor(data: {id?: string, title: string, reason: string|null, excerpt: string|null, featuredImage: string|null, favicon: string|null, url: URL, oneClickSubscribe: boolean, createdAt?: Date, updatedAt?: Date|null}) { - this.id = data.id ?? ObjectId().toString(); + private constructor(data: RecommendationConstructorData) { + this.id = data.id; this.title = data.title; this.reason = data.reason; this.excerpt = data.excerpt; @@ -39,19 +42,95 @@ export class Recommendation { this.favicon = data.favicon; this.url = data.url; this.oneClickSubscribe = data.oneClickSubscribe; - this.createdAt = data.createdAt ?? new Date(); - this.createdAt.setMilliseconds(0); - this.updatedAt = data.updatedAt ?? null; - this.updatedAt?.setMilliseconds(0); + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; this.#deleted = false; } - edit(properties: Partial) { - Object.assign(this, properties); - this.createdAt.setMilliseconds(0); + static validate(properties: AddRecommendation) { + if (properties.url.protocol !== 'http:' && properties.url.protocol !== 'https:') { + throw new errors.ValidationError({ + message: 'url must be a valid URL', + }); + } - this.updatedAt = new Date(); - this.updatedAt.setMilliseconds(0); + if (properties.featuredImage !== null) { + if (properties.featuredImage.protocol !== 'http:' && properties.featuredImage.protocol !== 'https:') { + throw new errors.ValidationError({ + message: 'Featured image must be a valid URL', + }); + } + } + + if (properties.favicon !== null) { + if (properties.favicon.protocol !== 'http:' && properties.favicon.protocol !== 'https:') { + throw new errors.ValidationError({ + message: 'Favicon must be a valid URL', + }); + } + } + + if (properties.title.length === 0) { + throw new errors.ValidationError({ + message: 'Title must not be empty', + }); + } + + if (properties.title.length > 2000) { + throw new errors.ValidationError({ + message: 'Title must be less than 2000 characters', + }); + } + + if (properties.reason && properties.reason.length > 2000) { + throw new errors.ValidationError({ + message: 'Reason must be less than 2000 characters', + }); + } + + if (properties.excerpt && properties.excerpt.length > 2000) { + throw new errors.ValidationError({ + message: 'Excerpt must be less than 2000 characters', + }); + } + } + + clean() { + if (this.reason !== null && this.reason.length === 0) { + this.reason = null; + } + + this.createdAt.setMilliseconds(0); + this.updatedAt?.setMilliseconds(0); + } + + static create(data: RecommendationCreateData) { + const id = data.id ?? ObjectId().toString(); + + const d = { + id, + title: data.title, + reason: data.reason, + excerpt: data.excerpt, + featuredImage: data.featuredImage, + favicon: data.favicon, + url: data.url, + oneClickSubscribe: data.oneClickSubscribe, + createdAt: data.createdAt ?? new Date(), + updatedAt: data.updatedAt ?? null, + }; + + this.validate(d); + const recommendation = new Recommendation(d); + recommendation.clean(); + return recommendation; + } + + edit(properties: EditRecommendation) { + Recommendation.validate({...this, ...properties}); + + Object.assign(this, properties); + this.clean(); } delete() { diff --git a/ghost/recommendations/src/RecommendationController.ts b/ghost/recommendations/src/RecommendationController.ts index e2e0dea566..b085d1d5e2 100644 --- a/ghost/recommendations/src/RecommendationController.ts +++ b/ghost/recommendations/src/RecommendationController.ts @@ -70,7 +70,7 @@ export class RecommendationController { return id; } - #getFrameRecommendation(frame: Frame): Recommendation { + #getFrameRecommendation(frame: Frame): AddRecommendation { if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) { throw new errors.BadRequestError(); } @@ -85,12 +85,12 @@ export class RecommendationController { oneClickSubscribe: validateBoolean(recommendation, "one_click_subscribe", {required: false}) ?? false, reason: validateString(recommendation, "reason", {required: false}) ?? null, excerpt: validateString(recommendation, "excerpt", {required: false}) ?? null, - featuredImage: validateString(recommendation, "featured_image", {required: false}) ?? null, - favicon: validateString(recommendation, "favicon", {required: false}) ?? null, + featuredImage: validateURL(recommendation, "featured_image", {required: false}) ?? null, + favicon: validateURL(recommendation, "favicon", {required: false}) ?? null, }; // Create a new recommendation - return new Recommendation(cleanedRecommendation); + return cleanedRecommendation; } #getFrameRecommendationEdit(frame: Frame): Partial { @@ -105,8 +105,8 @@ export class RecommendationController { oneClickSubscribe: validateBoolean(recommendation, "one_click_subscribe", {required: false}), reason: validateString(recommendation, "reason", {required: false}), excerpt: validateString(recommendation, "excerpt", {required: false}), - featuredImage: validateString(recommendation, "featured_image", {required: false}), - favicon: validateString(recommendation, "favicon", {required: false}), + featuredImage: validateURL(recommendation, "featured_image", {required: false}), + favicon: validateURL(recommendation, "favicon", {required: false}), }; // Create a new recommendation @@ -122,8 +122,8 @@ export class RecommendationController { title: r.title, reason: r.reason, excerpt: r.excerpt, - featured_image: r.featuredImage, - favicon: r.favicon, + featured_image: r.featuredImage?.toString() ?? null, + favicon: r.favicon?.toString() ?? null, url: r.url.toString(), one_click_subscribe: r.oneClickSubscribe, created_at: r.createdAt, diff --git a/ghost/recommendations/src/RecommendationService.ts b/ghost/recommendations/src/RecommendationService.ts index c540ee4987..c376c7b260 100644 --- a/ghost/recommendations/src/RecommendationService.ts +++ b/ghost/recommendations/src/RecommendationService.ts @@ -1,4 +1,4 @@ -import {Recommendation} from "./Recommendation"; +import {AddRecommendation, Recommendation} from "./Recommendation"; import {RecommendationRepository} from "./RecommendationRepository"; import {WellknownService} from "./WellknownService"; import errors from "@tryghost/errors"; @@ -32,7 +32,7 @@ export class RecommendationService { await this.wellknownService.set(recommendations); } - sendMentionToRecommendation(recommendation: Recommendation) { + private sendMentionToRecommendation(recommendation: Recommendation) { this.mentionSendingService.sendAll({ url: this.wellknownService.getURL(), links: [ @@ -41,7 +41,8 @@ export class RecommendationService { }).catch(console.error); } - async addRecommendation(recommendation: Recommendation) { + async addRecommendation(addRecommendation: AddRecommendation) { + const recommendation = Recommendation.create(addRecommendation); this.repository.save(recommendation); await this.updateWellknown(); diff --git a/ghost/recommendations/src/index.ts b/ghost/recommendations/src/index.ts index 9ae03734fb..db9ac91301 100644 --- a/ghost/recommendations/src/index.ts +++ b/ghost/recommendations/src/index.ts @@ -4,3 +4,4 @@ export * from './RecommendationRepository'; export * from './InMemoryRecommendationRepository'; export * from './Recommendation'; export * from './WellknownService'; +export * from './BookshelfRecommendationRepository'; diff --git a/ghost/recommendations/src/libraries.d.ts b/ghost/recommendations/src/libraries.d.ts index 0c51d8d42e..30ebbde8f2 100644 --- a/ghost/recommendations/src/libraries.d.ts +++ b/ghost/recommendations/src/libraries.d.ts @@ -1,2 +1,3 @@ declare module '@tryghost/errors'; declare module '@tryghost/tpl'; +declare module '@tryghost/logging';