ghost 5.111.0 → 5.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/components/{tryghost-adapter-cache-redis-5.111.0.tgz → tryghost-adapter-cache-redis-5.113.0.tgz} +0 -0
  2. package/components/{tryghost-adapter-manager-5.111.0.tgz → tryghost-adapter-manager-5.113.0.tgz} +0 -0
  3. package/components/tryghost-announcement-bar-settings-5.113.0.tgz +0 -0
  4. package/components/{tryghost-api-framework-5.111.0.tgz → tryghost-api-framework-5.113.0.tgz} +0 -0
  5. package/components/{tryghost-api-version-compatibility-service-5.111.0.tgz → tryghost-api-version-compatibility-service-5.113.0.tgz} +0 -0
  6. package/components/{tryghost-audience-feedback-5.111.0.tgz → tryghost-audience-feedback-5.113.0.tgz} +0 -0
  7. package/components/tryghost-bookshelf-repository-5.113.0.tgz +0 -0
  8. package/components/{tryghost-bootstrap-socket-5.111.0.tgz → tryghost-bootstrap-socket-5.113.0.tgz} +0 -0
  9. package/components/tryghost-captcha-service-5.113.0.tgz +0 -0
  10. package/components/tryghost-constants-5.113.0.tgz +0 -0
  11. package/components/tryghost-custom-fonts-5.113.0.tgz +0 -0
  12. package/components/tryghost-custom-theme-settings-service-5.113.0.tgz +0 -0
  13. package/components/{tryghost-data-generator-5.111.0.tgz → tryghost-data-generator-5.113.0.tgz} +0 -0
  14. package/components/tryghost-domain-events-5.113.0.tgz +0 -0
  15. package/components/tryghost-donations-5.113.0.tgz +0 -0
  16. package/components/tryghost-email-addresses-5.113.0.tgz +0 -0
  17. package/components/{tryghost-email-analytics-provider-mailgun-5.111.0.tgz → tryghost-email-analytics-provider-mailgun-5.113.0.tgz} +0 -0
  18. package/components/tryghost-email-analytics-service-5.113.0.tgz +0 -0
  19. package/components/{tryghost-email-content-generator-5.111.0.tgz → tryghost-email-content-generator-5.113.0.tgz} +0 -0
  20. package/components/tryghost-email-events-5.113.0.tgz +0 -0
  21. package/components/tryghost-email-service-5.113.0.tgz +0 -0
  22. package/components/{tryghost-email-suppression-list-5.111.0.tgz → tryghost-email-suppression-list-5.113.0.tgz} +0 -0
  23. package/components/{tryghost-express-dynamic-redirects-5.111.0.tgz → tryghost-express-dynamic-redirects-5.113.0.tgz} +0 -0
  24. package/components/tryghost-extract-api-key-5.113.0.tgz +0 -0
  25. package/components/tryghost-ghost-5.113.0.tgz +0 -0
  26. package/components/{tryghost-html-to-plaintext-5.111.0.tgz → tryghost-html-to-plaintext-5.113.0.tgz} +0 -0
  27. package/components/tryghost-i18n-5.113.0.tgz +0 -0
  28. package/components/{tryghost-identity-token-service-5.111.0.tgz → tryghost-identity-token-service-5.113.0.tgz} +0 -0
  29. package/components/{tryghost-importer-handler-content-files-5.111.0.tgz → tryghost-importer-handler-content-files-5.113.0.tgz} +0 -0
  30. package/components/{tryghost-importer-revue-5.111.0.tgz → tryghost-importer-revue-5.113.0.tgz} +0 -0
  31. package/components/tryghost-in-memory-repository-5.113.0.tgz +0 -0
  32. package/components/tryghost-job-manager-5.113.0.tgz +0 -0
  33. package/components/tryghost-link-redirects-5.113.0.tgz +0 -0
  34. package/components/tryghost-link-replacer-5.113.0.tgz +0 -0
  35. package/components/{tryghost-magic-link-5.111.0.tgz → tryghost-magic-link-5.113.0.tgz} +0 -0
  36. package/components/tryghost-mail-events-5.113.0.tgz +0 -0
  37. package/components/tryghost-mailgun-client-5.113.0.tgz +0 -0
  38. package/components/tryghost-member-attribution-5.113.0.tgz +0 -0
  39. package/components/tryghost-member-events-5.113.0.tgz +0 -0
  40. package/components/{tryghost-members-api-5.111.0.tgz → tryghost-members-api-5.113.0.tgz} +0 -0
  41. package/components/{tryghost-members-csv-5.111.0.tgz → tryghost-members-csv-5.113.0.tgz} +0 -0
  42. package/components/{tryghost-members-importer-5.111.0.tgz → tryghost-members-importer-5.113.0.tgz} +0 -0
  43. package/components/{tryghost-members-offers-5.111.0.tgz → tryghost-members-offers-5.113.0.tgz} +0 -0
  44. package/components/tryghost-members-payments-5.113.0.tgz +0 -0
  45. package/components/{tryghost-members-ssr-5.111.0.tgz → tryghost-members-ssr-5.113.0.tgz} +0 -0
  46. package/components/{tryghost-members-stripe-service-5.111.0.tgz → tryghost-members-stripe-service-5.113.0.tgz} +0 -0
  47. package/components/tryghost-milestones-5.113.0.tgz +0 -0
  48. package/components/{tryghost-minifier-5.111.0.tgz → tryghost-minifier-5.113.0.tgz} +0 -0
  49. package/components/{tryghost-mw-api-version-mismatch-5.111.0.tgz → tryghost-mw-api-version-mismatch-5.113.0.tgz} +0 -0
  50. package/components/tryghost-mw-cache-control-5.113.0.tgz +0 -0
  51. package/components/{tryghost-mw-error-handler-5.111.0.tgz → tryghost-mw-error-handler-5.113.0.tgz} +0 -0
  52. package/components/{tryghost-mw-session-from-token-5.111.0.tgz → tryghost-mw-session-from-token-5.113.0.tgz} +0 -0
  53. package/components/{tryghost-mw-update-user-last-seen-5.111.0.tgz → tryghost-mw-update-user-last-seen-5.113.0.tgz} +0 -0
  54. package/components/{tryghost-mw-version-match-5.111.0.tgz → tryghost-mw-version-match-5.113.0.tgz} +0 -0
  55. package/components/tryghost-mw-vhost-5.113.0.tgz +0 -0
  56. package/components/{tryghost-package-json-5.111.0.tgz → tryghost-package-json-5.113.0.tgz} +0 -0
  57. package/components/tryghost-post-events-5.113.0.tgz +0 -0
  58. package/components/tryghost-post-revisions-5.113.0.tgz +0 -0
  59. package/components/{tryghost-posts-service-5.111.0.tgz → tryghost-posts-service-5.113.0.tgz} +0 -0
  60. package/components/{tryghost-prometheus-metrics-5.111.0.tgz → tryghost-prometheus-metrics-5.113.0.tgz} +0 -0
  61. package/components/tryghost-recommendations-5.113.0.tgz +0 -0
  62. package/components/tryghost-referrers-5.113.0.tgz +0 -0
  63. package/components/{tryghost-security-5.111.0.tgz → tryghost-security-5.113.0.tgz} +0 -0
  64. package/components/tryghost-session-service-5.113.0.tgz +0 -0
  65. package/components/{tryghost-settings-path-manager-5.111.0.tgz → tryghost-settings-path-manager-5.113.0.tgz} +0 -0
  66. package/components/{tryghost-slack-notifications-5.111.0.tgz → tryghost-slack-notifications-5.113.0.tgz} +0 -0
  67. package/components/{tryghost-tiers-5.111.0.tgz → tryghost-tiers-5.113.0.tgz} +0 -0
  68. package/components/tryghost-version-notifications-data-service-5.113.0.tgz +0 -0
  69. package/components/{tryghost-webmentions-5.111.0.tgz → tryghost-webmentions-5.113.0.tgz} +0 -0
  70. package/core/boot.js +0 -3
  71. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +13043 -11763
  72. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-1298238e.mjs → CodeEditorView-ed5e87be.mjs} +2 -2
  73. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  74. package/core/built/admin/assets/admin-x-settings/{index-0f51ccb5.mjs → index-0ee4d13c.mjs} +2 -2
  75. package/core/built/admin/assets/admin-x-settings/{index-2707471f.mjs → index-9c7da716.mjs} +20224 -20178
  76. package/core/built/admin/assets/admin-x-settings/{modals-f5983704.mjs → modals-7708d510.mjs} +2227 -2216
  77. package/core/built/admin/assets/{chunk.524.405c43b2cb20553b51d9.js → chunk.524.4f0aeb6b611079e528f5.js} +7 -7
  78. package/core/built/admin/assets/{chunk.582.eb4b096f29c97c9d6a64.js → chunk.582.485df00698ed27a0668b.js} +8 -8
  79. package/core/built/admin/assets/{ghost-87cffc153ec73d217c1ae9f9207ea5e1.js → ghost-ebf07ae7768b6e9fb9a4b173b6917782.js} +72 -70
  80. package/core/built/admin/assets/img/ap-nodes-01ee317529e6353a1c34a062c388f1e7.png +0 -0
  81. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12944 -12924
  82. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +136 -134
  83. package/core/built/admin/assets/posts/posts.js +2 -2
  84. package/core/built/admin/assets/{vendor-fca15534b8426c0567400113c63a3e21.js → vendor-68a4aa424a179a90f5bbc2b750def576.js} +28 -26
  85. package/core/built/admin/index.html +4 -4
  86. package/core/frontend/helpers/get.js +2 -3
  87. package/core/frontend/services/routing/registry.js +6 -6
  88. package/core/frontend/src/admin-auth/message-handler.js +1 -1
  89. package/core/frontend/src/cards/css/cta.css +38 -36
  90. package/core/server/adapters/cache/AdapterCacheMemoryTTL.js +54 -0
  91. package/core/server/adapters/cache/memory-ttl.js +1 -1
  92. package/core/server/api/endpoints/utils/serializers/input/settings.js +2 -1
  93. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +2 -1
  94. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +2 -1
  95. package/core/server/data/migrations/versions/5.112/2025-03-10-10-01-01-add-require-mfa-setting.js +8 -0
  96. package/core/server/data/migrations/versions/5.113/2025-03-07-12-24-00-add-super-editor.js +31 -0
  97. package/core/server/data/migrations/versions/5.113/2025-03-07-12-25-00-add-member-perms-to-super-editor.js +291 -0
  98. package/core/server/data/schema/default-settings/default-settings.json +6 -0
  99. package/core/server/data/schema/fixtures/fixtures.json +27 -0
  100. package/core/server/models/invite.js +6 -7
  101. package/core/server/models/post.js +3 -9
  102. package/core/server/models/relations/authors.js +2 -4
  103. package/core/server/models/role-utils.js +38 -0
  104. package/core/server/models/role.js +7 -5
  105. package/core/server/models/user.js +41 -28
  106. package/core/server/services/activitypub/ActivityPubService.js +116 -0
  107. package/core/server/services/activitypub/ActivityPubService.ts +139 -0
  108. package/core/server/services/activitypub/ActivityPubServiceWrapper.js +1 -1
  109. package/core/server/services/email-analytics/jobs/update-member-email-analytics/index.js +13 -0
  110. package/core/server/services/email-analytics/lib/queries.js +3 -3
  111. package/core/server/services/media-inliner/ExternalMediaInliner.js +346 -0
  112. package/core/server/services/media-inliner/service.js +1 -1
  113. package/core/server/services/permissions/can-this.js +3 -2
  114. package/core/server/services/stats/MembersStatsService.js +167 -0
  115. package/core/server/services/stats/MrrStatsService.js +161 -0
  116. package/core/server/services/stats/ReferrersStatsService.js +164 -0
  117. package/core/server/services/stats/StatsService.js +63 -0
  118. package/core/server/services/stats/SubscriptionStatsService.js +180 -0
  119. package/core/server/services/stats/service.js +1 -1
  120. package/core/server/services/url/Resources.js +20 -30
  121. package/core/server/services/url/UrlService.js +2 -12
  122. package/core/server/services/url/Urls.js +17 -33
  123. package/core/shared/config/defaults.json +1 -1
  124. package/core/shared/labs.js +2 -1
  125. package/core/shared/settings-cache/CacheManager.js +4 -4
  126. package/package.json +139 -142
  127. package/tsconfig.tsbuildinfo +1 -1
  128. package/yarn.lock +40 -82
  129. package/components/tryghost-activitypub-5.111.0.tgz +0 -0
  130. package/components/tryghost-adapter-cache-memory-ttl-5.111.0.tgz +0 -0
  131. package/components/tryghost-announcement-bar-settings-5.111.0.tgz +0 -0
  132. package/components/tryghost-bookshelf-repository-5.111.0.tgz +0 -0
  133. package/components/tryghost-captcha-service-5.111.0.tgz +0 -0
  134. package/components/tryghost-constants-5.111.0.tgz +0 -0
  135. package/components/tryghost-custom-fonts-5.111.0.tgz +0 -0
  136. package/components/tryghost-custom-theme-settings-service-5.111.0.tgz +0 -0
  137. package/components/tryghost-domain-events-5.111.0.tgz +0 -0
  138. package/components/tryghost-donations-5.111.0.tgz +0 -0
  139. package/components/tryghost-email-addresses-5.111.0.tgz +0 -0
  140. package/components/tryghost-email-analytics-service-5.111.0.tgz +0 -0
  141. package/components/tryghost-email-events-5.111.0.tgz +0 -0
  142. package/components/tryghost-email-service-5.111.0.tgz +0 -0
  143. package/components/tryghost-external-media-inliner-5.111.0.tgz +0 -0
  144. package/components/tryghost-extract-api-key-5.111.0.tgz +0 -0
  145. package/components/tryghost-ghost-5.111.0.tgz +0 -0
  146. package/components/tryghost-i18n-5.111.0.tgz +0 -0
  147. package/components/tryghost-in-memory-repository-5.111.0.tgz +0 -0
  148. package/components/tryghost-job-manager-5.111.0.tgz +0 -0
  149. package/components/tryghost-link-redirects-5.111.0.tgz +0 -0
  150. package/components/tryghost-link-replacer-5.111.0.tgz +0 -0
  151. package/components/tryghost-mail-events-5.111.0.tgz +0 -0
  152. package/components/tryghost-mailgun-client-5.111.0.tgz +0 -0
  153. package/components/tryghost-member-attribution-5.111.0.tgz +0 -0
  154. package/components/tryghost-member-events-5.111.0.tgz +0 -0
  155. package/components/tryghost-members-payments-5.111.0.tgz +0 -0
  156. package/components/tryghost-milestones-5.111.0.tgz +0 -0
  157. package/components/tryghost-mw-cache-control-5.111.0.tgz +0 -0
  158. package/components/tryghost-mw-vhost-5.111.0.tgz +0 -0
  159. package/components/tryghost-post-events-5.111.0.tgz +0 -0
  160. package/components/tryghost-post-revisions-5.111.0.tgz +0 -0
  161. package/components/tryghost-recommendations-5.111.0.tgz +0 -0
  162. package/components/tryghost-referrers-5.111.0.tgz +0 -0
  163. package/components/tryghost-session-service-5.111.0.tgz +0 -0
  164. package/components/tryghost-stats-service-5.111.0.tgz +0 -0
  165. package/components/tryghost-version-notifications-data-service-5.111.0.tgz +0 -0
  166. package/core/demo.js +0 -6
  167. package/core/demo.ts +0 -3
@@ -0,0 +1,346 @@
1
+ const mime = require('mime-types');
2
+ const FileType = require('file-type');
3
+ const request = require('@tryghost/request');
4
+ const errors = require('@tryghost/errors');
5
+ const logging = require('@tryghost/logging');
6
+ const string = require('@tryghost/string');
7
+ const path = require('path');
8
+
9
+ class ExternalMediaInliner {
10
+ /** @type {object} */
11
+ #PostModel;
12
+
13
+ /** @type {object} */
14
+ #PostMetaModel;
15
+
16
+ /** @type {object} */
17
+ #TagModel;
18
+
19
+ /** @type {object} */
20
+ #UserModel;
21
+
22
+ /**
23
+ *
24
+ * @param {Object} deps
25
+ * @param {Object} deps.PostModel - Post model
26
+ * @param {Object} deps.PostMetaModel - PostMeta model
27
+ * @param {Object} deps.TagModel - Tag model
28
+ * @param {Object} deps.UserModel - User model
29
+ * @param {(extension) => import('ghost-storage-base')} deps.getMediaStorage - getMediaStorage
30
+ */
31
+ constructor(deps) {
32
+ this.#PostModel = deps.PostModel;
33
+ this.#PostMetaModel = deps.PostMetaModel;
34
+ this.#TagModel = deps.TagModel;
35
+ this.#UserModel = deps.UserModel;
36
+ this.getMediaStorage = deps.getMediaStorage;
37
+ }
38
+
39
+ /**
40
+ *
41
+ * @param {string} requestURL - url of remote media
42
+ * @returns {Promise<Object>}
43
+ */
44
+ async getRemoteMedia(requestURL) {
45
+ // @NOTE: this is the most expensive operation in the whole inlining process
46
+ // we should consider caching the results to improve performance
47
+
48
+ // Enforce http - http > https redirects are commonplace
49
+ requestURL = requestURL.replace(/^\/\//g, 'http://');
50
+
51
+ // Encode to handle special characters in URLs
52
+ requestURL = encodeURI(requestURL);
53
+ try {
54
+ const response = await request(requestURL, {
55
+ followRedirect: true,
56
+ responseType: 'buffer'
57
+ });
58
+
59
+ return response;
60
+ } catch (error) {
61
+ // NOTE: add special case for 404s
62
+ logging.error(`Error downloading remote media: ${requestURL}`);
63
+ logging.error(new errors.DataImportError({
64
+ err: error
65
+ }));
66
+
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ *
73
+ * @param {Object} response - response from request
74
+ * @returns {Object}
75
+ */
76
+ async extractFileDataFromResponse(requestURL, response) {
77
+ let extension;
78
+
79
+ // Attempt to get the file extension from the file itself
80
+ // If that fails, or if `.ext` is undefined, get the extension from the file path in the catch
81
+ try {
82
+ const fileInfo = await FileType.fromBuffer(response.body);
83
+ extension = fileInfo.ext;
84
+ } catch {
85
+ const headers = response.headers;
86
+ const contentType = headers['content-type'];
87
+ const extensionFromPath = path.parse(requestURL).ext.split(/[^a-z]/i).filter(Boolean)[0];
88
+ extension = mime.extension(contentType) || extensionFromPath;
89
+ }
90
+
91
+ const removeExtRegExp = new RegExp(`.${extension}`, '');
92
+ const fileNameNoExt = path.parse(requestURL).base.replace(removeExtRegExp, '');
93
+
94
+ // CASE: Query strings _can_ form part of the unique image URL, so rather that strip them include the in the file name
95
+ // Then trim to last 248 chars (this will be more unique than the first 248), and trim leading & trailing dashes.
96
+ // 248 is on the lower end of limits from various OSes and file systems
97
+ const fileName = string.slugify(path.parse(fileNameNoExt).base, {
98
+ requiredChangesOnly: true
99
+ }).slice(-248).replace(/^-|-$/, '');
100
+
101
+ return {
102
+ fileBuffer: response.body,
103
+ filename: `${fileName}.${extension}`,
104
+ extension: `.${extension}`
105
+ };
106
+ }
107
+
108
+ /**
109
+ *
110
+ * @param {Object} media - media to store locally
111
+ * @returns {Promise<string>} - path to stored media
112
+ */
113
+ async storeMediaLocally(media) {
114
+ const storage = this.getMediaStorage(media.extension);
115
+
116
+ if (!storage) {
117
+ logging.warn(`No storage adapter found for file extension: ${media.extension}`);
118
+ return null;
119
+ } else {
120
+ // @NOTE: this is extremely convoluted and should live on a
121
+ // storage adapter level
122
+ const targetDir = storage.getTargetDir(storage.storagePath);
123
+ const uniqueFileName = await storage.getUniqueFileName({
124
+ name: media.filename
125
+ }, targetDir);
126
+ const targetPath = path.relative(storage.storagePath, uniqueFileName);
127
+ const filePath = await storage.saveRaw(media.fileBuffer, targetPath);
128
+ return filePath;
129
+ }
130
+ }
131
+
132
+ static findMatches(content, domain) {
133
+ // NOTE: the src could end with a quote, bracket, apostrophe, double-backslash, or encoded quote.
134
+ // Backlashes are added to content as an escape character
135
+ const srcTerminationSymbols = `("|\\)|'|(?=(?:,https?))| |<|\\\\|&quot;|$)`;
136
+ const regex = new RegExp(`(${domain}.*?)(${srcTerminationSymbols})`, 'igm');
137
+ const matches = content.matchAll(regex);
138
+
139
+ // Simplify the matches so we only get the result needed
140
+ let matchesArray = Array.from(matches, m => m[1]);
141
+
142
+ // Trim trailing commas from each match
143
+ matchesArray = matchesArray.map((item) => {
144
+ return item.replace(/,$/, '');
145
+ });
146
+
147
+ return matchesArray;
148
+ }
149
+
150
+ /**
151
+ * Find & inline external media from a JSON sting.
152
+ * This works with both Lexical & Mobiledoc, so no separate methods are needed here.
153
+ *
154
+ * @param {string} content - stringified JSON of post Lexical or Mobiledoc content
155
+ * @param {String[]} domains - domains to inline media from
156
+ * @returns {Promise<string>} - updated stringified JSON of post content
157
+ */
158
+ async inlineContent(content, domains) {
159
+ for (const domain of domains) {
160
+ const matches = this.constructor.findMatches(content, domain);
161
+
162
+ for (const src of matches) {
163
+ const response = await this.getRemoteMedia(src);
164
+
165
+ let media;
166
+ if (response) {
167
+ media = await this.extractFileDataFromResponse(src, response);
168
+ }
169
+
170
+ if (media) {
171
+ const filePath = await this.storeMediaLocally(media);
172
+
173
+ if (filePath) {
174
+ const inlinedSrc = `__GHOST_URL__${filePath}`;
175
+
176
+ // NOTE: does not account for duplicate images in content
177
+ // in those cases would be processed twice
178
+ content = content.replace(src, inlinedSrc);
179
+ logging.info(`Inlined media: ${src} -> ${inlinedSrc}`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ return content;
186
+ }
187
+
188
+ /**
189
+ *
190
+ * @param {Object} resourceModel - one of PostModel, TagModel, UserModel instances
191
+ * @param {String[]} fields - fields to inline
192
+ * @param {String[]} domains - domains to inline media from
193
+ * @returns Promise<Object> - updated fields map with local media paths
194
+ */
195
+ async inlineFields(resourceModel, fields, domains) {
196
+ const updatedFields = {};
197
+
198
+ for (const field of fields) {
199
+ for (const domain of domains) {
200
+ const src = resourceModel.get(field);
201
+
202
+ if (src && src.startsWith(domain)) {
203
+ const response = await this.getRemoteMedia(src);
204
+
205
+ let media;
206
+ if (response) {
207
+ media = await this.extractFileDataFromResponse(src, response);
208
+ }
209
+
210
+ if (media) {
211
+ const filePath = await this.storeMediaLocally(media);
212
+
213
+ if (filePath) {
214
+ const inlinedSrc = `__GHOST_URL__${filePath}`;
215
+
216
+ updatedFields[field] = inlinedSrc;
217
+ logging.info(`Added media to inline: ${src} -> ${inlinedSrc}`);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ return updatedFields;
225
+ }
226
+
227
+ /**
228
+ *
229
+ * @param {Object[]} resources - array of model instances
230
+ * @param {Object} model - resource model
231
+ * @param {string[]} fields - fields to inline
232
+ * @param {string[]} domains - domains to inline media from
233
+ */
234
+ async inlineSimpleFields(resources, model, fields, domains) {
235
+ logging.info(`Starting inlining external media for ${resources?.length} resources and with ${fields.join(', ')} fields`);
236
+
237
+ for (const resource of resources) {
238
+ try {
239
+ const updatedFields = await this.inlineFields(resource, fields, domains);
240
+
241
+ if (Object.keys(updatedFields).length > 0) {
242
+ await model.edit(updatedFields, {
243
+ id: resource.id,
244
+ context: {
245
+ internal: true
246
+ }
247
+ });
248
+ }
249
+ } catch (err) {
250
+ logging.error(`Error inlining media for: ${resource.id}`);
251
+ logging.error(new errors.DataImportError({
252
+ err
253
+ }));
254
+ }
255
+ }
256
+ }
257
+
258
+ /**
259
+ *
260
+ * @param {string[]} domains domains to inline media from
261
+ */
262
+ async inline(domains) {
263
+ const posts = await this.#PostModel.findAll({context: {internal: true}});
264
+ const postsInilingFields = [
265
+ 'feature_image'
266
+ ];
267
+
268
+ logging.info(`Starting inlining external media for posts: ${posts?.length}`);
269
+
270
+ for (const post of posts) {
271
+ try {
272
+ const mobiledocContent = post.get('mobiledoc');
273
+ const lexicalContent = post.get('lexical');
274
+
275
+ const updatedFields = await this.inlineFields(post, postsInilingFields, domains);
276
+
277
+ if (mobiledocContent) {
278
+ const inlinedContent = await this.inlineContent(mobiledocContent, domains);
279
+
280
+ // If content has changed, update the post
281
+ if (inlinedContent !== mobiledocContent) {
282
+ updatedFields.mobiledoc = inlinedContent;
283
+ }
284
+ }
285
+
286
+ if (lexicalContent) {
287
+ const inlinedContent = await this.inlineContent(lexicalContent, domains);
288
+
289
+ // If content has changed, update the post
290
+ if (inlinedContent !== lexicalContent) {
291
+ updatedFields.lexical = inlinedContent;
292
+ }
293
+ }
294
+
295
+ if (Object.keys(updatedFields).length > 0) {
296
+ await this.#PostModel.edit(updatedFields, {
297
+ id: post.id,
298
+ context: {
299
+ internal: true
300
+ }
301
+ });
302
+ }
303
+ } catch (err) {
304
+ logging.error(`Error inlining media for post: ${post.id}`);
305
+ logging.error(new errors.DataImportError({
306
+ err
307
+ }));
308
+ }
309
+ }
310
+
311
+ const {data: postsMetas} = await this.#PostMetaModel.findPage({
312
+ limit: 'all'
313
+ });
314
+ const postsMetaInilingFields = [
315
+ 'og_image',
316
+ 'twitter_image'
317
+ ];
318
+
319
+ await this.inlineSimpleFields(postsMetas, this.#PostMetaModel, postsMetaInilingFields, domains);
320
+
321
+ const {data: tags} = await this.#TagModel.findPage({
322
+ limit: 'all'
323
+ });
324
+ const tagInliningFields = [
325
+ 'feature_image',
326
+ 'og_image',
327
+ 'twitter_image'
328
+ ];
329
+
330
+ await this.inlineSimpleFields(tags, this.#TagModel, tagInliningFields, domains);
331
+
332
+ const {data: users} = await this.#UserModel.findPage({
333
+ limit: 'all'
334
+ });
335
+ const userInliningFields = [
336
+ 'profile_image',
337
+ 'cover_image'
338
+ ];
339
+
340
+ await this.inlineSimpleFields(users, this.#UserModel, userInliningFields, domains);
341
+
342
+ logging.info('Finished inlining external media for posts, tags, and users');
343
+ }
344
+ }
345
+
346
+ module.exports = ExternalMediaInliner;
@@ -1,7 +1,7 @@
1
1
  module.exports = {
2
2
  async init() {
3
3
  const debug = require('@tryghost/debug')('mediaInliner');
4
- const MediaInliner = require('@tryghost/external-media-inliner');
4
+ const MediaInliner = require('./ExternalMediaInliner');
5
5
  const models = require('../../models');
6
6
  const jobsService = require('../jobs');
7
7
 
@@ -5,6 +5,7 @@ const tpl = require('@tryghost/tpl');
5
5
  const providers = require('./providers');
6
6
  const parseContext = require('./parse-context');
7
7
  const actionsMap = require('./actions-map-cache');
8
+ const {setIsRoles} = require('../../models/role-utils');
8
9
 
9
10
  const messages = {
10
11
  noPermissionToAction: 'You do not have permission to perform this action',
@@ -66,8 +67,8 @@ class CanThisResult {
66
67
 
67
68
  return true;
68
69
  };
69
-
70
- if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Owner'})) {
70
+ const {isOwner} = setIsRoles(loadedPermissions);
71
+ if (isOwner) {
71
72
  hasUserPermission = true;
72
73
  } else if (!_.isEmpty(userPermissions)) {
73
74
  hasUserPermission = _.some(userPermissions, checkPermission);
@@ -0,0 +1,167 @@
1
+ const moment = require('moment');
2
+
3
+ class MembersStatsService {
4
+ /**
5
+ * @param {object} deps
6
+ * @param {import('knex').Knex} deps.knex*/
7
+ constructor({knex}) {
8
+ this.knex = knex;
9
+ }
10
+
11
+ /**
12
+ * Get the current total members grouped by status
13
+ * @returns {Promise<TotalMembersByStatus>}
14
+ */
15
+ async getCount() {
16
+ const knex = this.knex;
17
+ const rows = await knex('members')
18
+ .select('status')
19
+ .select(knex.raw('COUNT(id) AS total'))
20
+ .groupBy('status');
21
+
22
+ const paidEvent = rows.find(c => c.status === 'paid');
23
+ const freeEvent = rows.find(c => c.status === 'free');
24
+ const compedEvent = rows.find(c => c.status === 'comped');
25
+
26
+ return {
27
+ paid: paidEvent ? paidEvent.total : 0,
28
+ free: freeEvent ? freeEvent.total : 0,
29
+ comped: compedEvent ? compedEvent.total : 0
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Get the member deltas by status for all days, sorted ascending
35
+ * @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted ascending
36
+ */
37
+ async fetchAllStatusDeltas() {
38
+ const knex = this.knex;
39
+ const ninetyDaysAgo = moment.utc().subtract(91, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
40
+ const rows = await knex('members_status_events')
41
+ .select(knex.raw('DATE(created_at) as date'))
42
+ .select(knex.raw(`SUM(
43
+ CASE WHEN to_status='paid' THEN 1
44
+ ELSE 0 END
45
+ ) as paid_subscribed`))
46
+ .select(knex.raw(`SUM(
47
+ CASE WHEN from_status='paid' THEN 1
48
+ ELSE 0 END
49
+ ) as paid_canceled`))
50
+ .select(knex.raw(`SUM(
51
+ CASE WHEN to_status='comped' THEN 1
52
+ WHEN from_status='comped' THEN -1
53
+ ELSE 0 END
54
+ ) as comped_delta`))
55
+ .select(knex.raw(`SUM(
56
+ CASE WHEN to_status='free' THEN 1
57
+ WHEN from_status='free' THEN -1
58
+ ELSE 0 END
59
+ ) as free_delta`))
60
+ .where('created_at', '>=', ninetyDaysAgo)
61
+ .groupByRaw('DATE(created_at)');
62
+ return rows;
63
+ }
64
+
65
+ /**
66
+ * Returns a list of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
67
+ * @returns {Promise<CountHistory>}
68
+ */
69
+ async getCountHistory() {
70
+ const rows = await this.fetchAllStatusDeltas();
71
+
72
+ // Fetch current total amounts and start counting from there
73
+ const totals = await this.getCount();
74
+ let {paid, free, comped} = totals;
75
+
76
+ // Get today in UTC (default timezone)
77
+ const today = moment().format('YYYY-MM-DD');
78
+
79
+ const cumulativeResults = [];
80
+
81
+ rows.sort((a, b) => new Date(a.date) - new Date(b.date));
82
+ // Loop in reverse order (needed to have correct sorted result)
83
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
84
+ const row = rows[i];
85
+
86
+ // Convert JSDates to YYYY-MM-DD (in UTC)
87
+ const date = moment(row.date).format('YYYY-MM-DD');
88
+ if (date > today) {
89
+ // Skip results that are in the future (fix for invalid events)
90
+ continue;
91
+ }
92
+ cumulativeResults.unshift({
93
+ date,
94
+ paid: Math.max(0, paid),
95
+ free: Math.max(0, free),
96
+ comped: Math.max(0, comped),
97
+
98
+ // Deltas
99
+ paid_subscribed: row.paid_subscribed,
100
+ paid_canceled: row.paid_canceled
101
+ });
102
+
103
+ // Update current counts
104
+ paid -= row.paid_subscribed - row.paid_canceled;
105
+ free -= row.free_delta;
106
+ comped -= row.comped_delta;
107
+ }
108
+
109
+ // Now also add the oldest day we have left over (this one will be zero, which is also needed as a data point for graphs)
110
+ const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
111
+
112
+ cumulativeResults.unshift({
113
+ date: oldestDate,
114
+ paid: Math.max(0, paid),
115
+ free: Math.max(0, free),
116
+ comped: Math.max(0, comped),
117
+
118
+ // Deltas
119
+ paid_subscribed: 0,
120
+ paid_canceled: 0
121
+ });
122
+
123
+ return {
124
+ data: cumulativeResults,
125
+ meta: {
126
+ totals
127
+ }
128
+ };
129
+ }
130
+ }
131
+
132
+ module.exports = MembersStatsService;
133
+
134
+ /**
135
+ * @typedef MemberStatusDelta
136
+ * @type {Object}
137
+ * @property {Date} date
138
+ * @property {number} paid_subscribed Paid members that subscribed on this day
139
+ * @property {number} paid_canceled Paid members that canceled on this day
140
+ * @property {number} comped_delta Total net comped members on this day
141
+ * @property {number} free_delta Total net members on this day
142
+ */
143
+
144
+ /**
145
+ * @typedef TotalMembersByStatus
146
+ * @type {Object}
147
+ * @property {number} paid Total paid members
148
+ * @property {number} free Total free members
149
+ * @property {number} comped Total comped members
150
+ */
151
+
152
+ /**
153
+ * @typedef {Object} TotalMembersByStatusItem
154
+ * @property {string} date In YYYY-MM-DD format
155
+ * @property {number} paid Total paid members
156
+ * @property {number} free Total free members
157
+ * @property {number} comped Total comped members
158
+ * @property {number} paid_subscribed Paid members that subscribed on this day
159
+ * @property {number} paid_canceled Paid members that canceled on this day
160
+ */
161
+
162
+ /**
163
+ * @typedef {Object} CountHistory
164
+ * @property {TotalMembersByStatusItem[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
165
+ * @property {Object} meta
166
+ * @property {TotalMembersByStatus} meta.totals
167
+ */
@@ -0,0 +1,161 @@
1
+ const moment = require('moment');
2
+
3
+ class MrrStatsService {
4
+ /**
5
+ * @param {object} deps
6
+ * @param {import('knex').Knex} deps.knex
7
+ **/
8
+ constructor({knex}) {
9
+ this.knex = knex;
10
+ }
11
+
12
+ /**
13
+ * Get the current total MRR, grouped by currency (ascending order)
14
+ * @returns {Promise<MrrByCurrency[]>}
15
+ */
16
+ async getCurrentMrr() {
17
+ const knex = this.knex;
18
+ const rows = await knex('members_stripe_customers_subscriptions')
19
+ .select(knex.raw(`plan_currency as currency`))
20
+ .select(knex.raw(`SUM(mrr) AS mrr`))
21
+ .groupBy('plan_currency')
22
+ .orderBy('currency');
23
+
24
+ if (rows.length === 0) {
25
+ // Add a USD placeholder to always have at least one currency
26
+ rows.push({
27
+ currency: 'usd',
28
+ mrr: 0
29
+ });
30
+ }
31
+
32
+ return rows;
33
+ }
34
+
35
+ /**
36
+ * Get the MRR deltas for all days (from old to new), grouped by currency (ascending alphabetically)
37
+ * @returns {Promise<MrrDelta[]>} The deltas sorted from new to old
38
+ */
39
+ async fetchAllDeltas() {
40
+ const knex = this.knex;
41
+ const ninetyDaysAgo = moment.utc().subtract(90, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
42
+ const rows = await knex('members_paid_subscription_events')
43
+ .select('currency')
44
+ // In SQLite, DATE(created_at) would map to a string value, while DATE(created_at) would map to a JSDate object in MySQL
45
+ // That is why we need the cast here (to have some consistency)
46
+ .select(knex.raw('CAST(DATE(created_at) as CHAR) as date'))
47
+ .select(knex.raw(`SUM(mrr_delta) as delta`))
48
+ .where('created_at', '>=', ninetyDaysAgo)
49
+ .groupByRaw('CAST(DATE(created_at) as CHAR), currency');
50
+ return rows;
51
+ }
52
+
53
+ /**
54
+ * Returns a list of the MRR history for each day and currency, including the current MRR per currency as meta data.
55
+ * The respons is in ascending date order, and currencies for the same date are always in ascending order.
56
+ * @returns {Promise<MrrHistory>}
57
+ */
58
+ async getHistory() {
59
+ // Fetch current total amounts and start counting from there
60
+ const totals = await this.getCurrentMrr();
61
+
62
+ const rows = await this.fetchAllDeltas();
63
+
64
+ rows.sort((rowA, rowB) => {
65
+ const dateA = new Date(rowA.date);
66
+ const dateB = new Date(rowB.date);
67
+
68
+ return dateA - dateB || rowA.currency.localeCompare(rowB.currency);
69
+ });
70
+
71
+ // Get today in UTC (default timezone)
72
+ const today = moment().format('YYYY-MM-DD');
73
+
74
+ const results = [];
75
+
76
+ // Create a map of the totals by currency for fast lookup and editing
77
+
78
+ /** @type {Object.<string, number>}*/
79
+ const currentTotals = {};
80
+ for (const total of totals) {
81
+ currentTotals[total.currency] = total.mrr;
82
+ }
83
+
84
+ // Loop in reverse order (needed to have correct sorted result)
85
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
86
+ const row = rows[i];
87
+
88
+ if (currentTotals[row.currency] === undefined) {
89
+ // Skip unexpected currencies that are not in the totals
90
+ continue;
91
+ }
92
+
93
+ // Convert JSDates to YYYY-MM-DD (in UTC)
94
+ const date = moment(row.date).format('YYYY-MM-DD');
95
+
96
+ if (date > today) {
97
+ // Skip results that are in the future for some reason
98
+ continue;
99
+ }
100
+
101
+ results.unshift({
102
+ date,
103
+ mrr: Math.max(0, currentTotals[row.currency]),
104
+ currency: row.currency
105
+ });
106
+
107
+ currentTotals[row.currency] -= row.delta;
108
+ }
109
+
110
+ // Now also add the oldest days we have left over and do not have deltas
111
+ const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
112
+
113
+ // Note that we also need to loop the totals in reverse order because we need to unshift
114
+ for (let i = totals.length - 1; i >= 0; i -= 1) {
115
+ const total = totals[i];
116
+ results.unshift({
117
+ date: oldestDate,
118
+ mrr: Math.max(0, currentTotals[total.currency]),
119
+ currency: total.currency
120
+ });
121
+ }
122
+
123
+ return {
124
+ data: results,
125
+ meta: {
126
+ totals
127
+ }
128
+ };
129
+ }
130
+ }
131
+
132
+ module.exports = MrrStatsService;
133
+
134
+ /**
135
+ * @typedef MrrByCurrency
136
+ * @type {Object}
137
+ * @property {number} mrr
138
+ * @property {string} currency
139
+ */
140
+
141
+ /**
142
+ * @typedef MrrDelta
143
+ * @type {Object}
144
+ * @property {Date} date
145
+ * @property {string} currency
146
+ * @property {number} delta MRR change on this day
147
+ */
148
+
149
+ /**
150
+ * @typedef {Object} MrrRecord
151
+ * @property {string} date In YYYY-MM-DD format
152
+ * @property {string} currency
153
+ * @property {number} mrr MRR on this day
154
+ */
155
+
156
+ /**
157
+ * @typedef {Object} MrrHistory
158
+ * @property {MrrRecord[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
159
+ * @property {Object} meta
160
+ * @property {MrrByCurrency[]} meta.totals
161
+ */