apostrophe 4.27.1 → 4.28.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 (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/index.js +3 -0
  3. package/lib/stream-proxy.js +49 -0
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +2 -11
  5. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +38 -6
  6. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +12 -1
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +111 -41
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +1 -0
  9. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +22 -10
  10. package/modules/@apostrophecms/area/ui/apos/logic/AposAreaEditor.js +40 -0
  11. package/modules/@apostrophecms/asset/index.js +3 -2
  12. package/modules/@apostrophecms/attachment/index.js +270 -0
  13. package/modules/@apostrophecms/doc/index.js +8 -2
  14. package/modules/@apostrophecms/doc-type/index.js +81 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +18 -2
  16. package/modules/@apostrophecms/express/index.js +30 -1
  17. package/modules/@apostrophecms/file/index.js +71 -6
  18. package/modules/@apostrophecms/i18n/index.js +20 -1
  19. package/modules/@apostrophecms/image/index.js +11 -0
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +31 -6
  21. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +12 -10
  22. package/modules/@apostrophecms/login/index.js +43 -11
  23. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +2 -1
  24. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +5 -0
  25. package/modules/@apostrophecms/page/index.js +9 -11
  26. package/modules/@apostrophecms/page-type/index.js +6 -1
  27. package/modules/@apostrophecms/piece-page-type/index.js +100 -13
  28. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposImageControlDialog.vue +1 -0
  29. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +28 -12
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +1 -0
  31. package/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +1 -1
  32. package/modules/@apostrophecms/styles/lib/apiRoutes.js +25 -5
  33. package/modules/@apostrophecms/styles/lib/handlers.js +19 -0
  34. package/modules/@apostrophecms/styles/lib/methods.js +35 -12
  35. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +7 -2
  36. package/modules/@apostrophecms/task/index.js +9 -1
  37. package/modules/@apostrophecms/template/views/outerLayoutBase.html +3 -0
  38. package/modules/@apostrophecms/ui/index.js +2 -0
  39. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  40. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +5 -0
  41. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +5 -0
  42. package/modules/@apostrophecms/ui/ui/apos/lib/vue.js +2 -0
  43. package/modules/@apostrophecms/ui/ui/apos/stores/widget.js +12 -7
  44. package/modules/@apostrophecms/ui/ui/apos/stores/widgetGraph.js +461 -0
  45. package/modules/@apostrophecms/ui/ui/apos/universal/graph.js +452 -0
  46. package/modules/@apostrophecms/ui/ui/apos/universal/widgetGraph.js +10 -0
  47. package/modules/@apostrophecms/uploadfs/index.js +15 -1
  48. package/modules/@apostrophecms/url/index.js +419 -1
  49. package/package.json +6 -6
  50. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  51. package/test/external-front.js +1 -0
  52. package/test/files.js +135 -0
  53. package/test/login-requirements.js +145 -3
  54. package/test/static-build.js +2701 -0
  55. package/test/universal-graph.js +1135 -0
@@ -124,6 +124,9 @@ module.exports = {
124
124
  await self.db.createIndex({ archivedDocIds: 1 });
125
125
  self.addLegacyMigrations();
126
126
  self.addSvgSanitizationMigration();
127
+
128
+ // Lazy cache for types whose schema contains attachment fields.
129
+ self.typesWithAttachmentFields = new Map();
127
130
  },
128
131
 
129
132
  tasks(self) {
@@ -633,6 +636,15 @@ module.exports = {
633
636
  if (!attachment) {
634
637
  return self.getMissingAttachmentUrl();
635
638
  }
639
+ // file module supports these, optionally
640
+ // (performance tradeoff). It's not enough to
641
+ // pass the prettyUrl: true option to this method,
642
+ // and that's not necessary. Setting it to false is
643
+ // an internal option for determining the real URL
644
+ // behind the pretty URL.
645
+ if (attachment._prettyUrl && (options.prettyUrl !== false)) {
646
+ return attachment._prettyUrl;
647
+ }
636
648
  let path = '/attachments/' + attachment._id + '-' + attachment.name;
637
649
  if (!options.uploadfsPath) {
638
650
  path = self.uploadfs.getUrl() + path;
@@ -882,6 +894,264 @@ module.exports = {
882
894
  }
883
895
  }
884
896
  },
897
+ // Check whether a schema array (recursively through
898
+ // array and object sub-schemas) contains at least one
899
+ // field with `type: 'attachment'`.
900
+ schemaHasAttachmentField(schema) {
901
+ if (!Array.isArray(schema)) {
902
+ return false;
903
+ }
904
+ for (const field of schema) {
905
+ if (field.type === 'attachment') {
906
+ return true;
907
+ }
908
+ if (
909
+ (field.type === 'array' || field.type === 'object') &&
910
+ field.schema
911
+ ) {
912
+ if (self.schemaHasAttachmentField(field.schema)) {
913
+ return true;
914
+ }
915
+ }
916
+ }
917
+ return false;
918
+ },
919
+
920
+ // Return a Set of doc type names whose module schema
921
+ // contains at least one `type: 'attachment'` field.
922
+ // Result is lazy cached.
923
+ hasAttachmentFields(type) {
924
+ if (self.typesWithAttachmentFields.has(type)) {
925
+ return self.typesWithAttachmentFields.get(type);
926
+ }
927
+ const module = self.apos.modules[type];
928
+ if (!module?.schema) {
929
+ self.typesWithAttachmentFields.set(type, false);
930
+ } else {
931
+ self.typesWithAttachmentFields.set(
932
+ type,
933
+ self.schemaHasAttachmentField(module.schema)
934
+ );
935
+ }
936
+ return self.typesWithAttachmentFields.get(type);
937
+ },
938
+
939
+ // Check whether a relationship field's `withType`
940
+ // (which may be a virtual type like
941
+ // `@apostrophecms/any-page-type`) resolves to a type
942
+ // that has attachment fields.
943
+ relationshipHasAttachmentFields(withType) {
944
+ if (self.hasAttachmentFields(withType)) {
945
+ return true;
946
+ }
947
+ if (
948
+ withType === '@apostrophecms/any-page-type' ||
949
+ withType === '@apostrophecms/page'
950
+ ) {
951
+ const cacheKey = '@apostrophecms/any-page-type';
952
+ if (!self.typesWithAttachmentFields.has(cacheKey)) {
953
+ self.typesWithAttachmentFields.set(
954
+ cacheKey,
955
+ self.apos
956
+ .instancesOf('@apostrophecms/page-type')
957
+ .some(module => self.hasAttachmentFields(module.__meta.name))
958
+ );
959
+ self.typesWithAttachmentFields.set(
960
+ '@apostrophecms/page',
961
+ self.typesWithAttachmentFields.get(cacheKey)
962
+ );
963
+ }
964
+ return self.typesWithAttachmentFields.get(cacheKey);
965
+ }
966
+ return false;
967
+ },
968
+
969
+ // Collect the full `_id` values (e.g. `abc:en:published`)
970
+ // of docs that are referenced via relationship fields
971
+ // from the given `doc` and belong to types whose schema
972
+ // contains at least one `type: 'attachment'` field.
973
+ //
974
+ // These IDs can be matched against the `docIds` array
975
+ // stored on each attachment record, allowing a "used"
976
+ // scope that only includes attachments actively referenced
977
+ // by published content.
978
+ //
979
+ // Returns an array of full `_id` strings. The method is
980
+ // synchronous because it only inspects the in-memory
981
+ // document data — no database queries are needed.
982
+ collectUsedDocIds(req, doc) {
983
+ const docIds = new Set();
984
+ if (self.hasAttachmentFields(doc.type)) {
985
+ docIds.add(doc.aposDocId);
986
+ }
987
+ const locale = req.locale || self.apos.i18n?.defaultLocale || 'en';
988
+ const mode = req.mode || 'published';
989
+
990
+ self.collectDocAttachmentRefIds(doc, docIds);
991
+
992
+ // Convert aposDocId values to full _id format
993
+ return [ ...docIds ].map(
994
+ aposDocId => `${aposDocId}:${locale}:${mode}`
995
+ );
996
+ },
997
+
998
+ // Given a single document (raw MongoDB data), walk its
999
+ // schema recursively and collect `idsStorage` values from
1000
+ // relationship fields that point to types with attachment
1001
+ // fields. Also handles the special case of rich-text
1002
+ // widget inline images (`imageIds`) and widgets whose own
1003
+ // schema contains a direct `type: 'attachment'` field.
1004
+ //
1005
+ // Found IDs are added to the `target` Set
1006
+ // (aposDocId values, locale-agnostic).
1007
+ collectDocAttachmentRefIds(doc, target) {
1008
+ const handlers = {
1009
+ relationship: (field, contextDoc) => {
1010
+ if (self.relationshipHasAttachmentFields(field.withType)) {
1011
+ for (const id of (contextDoc[field.idsStorage] || [])) {
1012
+ target.add(id);
1013
+ }
1014
+ }
1015
+ },
1016
+ // Rich-text widgets store inline image references in
1017
+ // `imageIds` outside of any schema relationship field.
1018
+ // Widgets whose own schema has a direct `type: 'attachment'`
1019
+ // field (e.g. a custom widget storing a file directly) also
1020
+ // need to include the parent doc's ID.
1021
+ widget: (field, widget) => {
1022
+ if (self.hasAttachmentFields(widget.type)) {
1023
+ target.add(doc.aposDocId);
1024
+ }
1025
+ if (
1026
+ widget.type === '@apostrophecms/rich-text' &&
1027
+ Array.isArray(widget.imageIds)
1028
+ ) {
1029
+ for (const id of widget.imageIds) {
1030
+ target.add(id);
1031
+ }
1032
+ }
1033
+ }
1034
+ };
1035
+ self.apos.doc.walkByMetaType(doc, handlers);
1036
+ },
1037
+
1038
+ // Return metadata for attachments suitable for a static build.
1039
+ // Uses batch-of-100 pattern for memory efficiency (see self.each()).
1040
+ //
1041
+ // Options (all optional):
1042
+ //
1043
+ // `docIds`: array of full locale-qualified document `_id`s.
1044
+ // When provided, only attachments whose `docIds` array
1045
+ // intersects with the given list are returned ("used"
1046
+ // scope). When not provided (undefined), all non-archived
1047
+ // attachments are returned ("all" scope).
1048
+ //
1049
+ // Archived attachments are always excluded.
1050
+ //
1051
+ // `sizes`: array of size names to include (e.g. `['full',
1052
+ // 'one-half']`). When provided, only these sizes are
1053
+ // emitted. Ignored for non-sized attachments (SVG, office
1054
+ // files) which always get a single entry with `path` only.
1055
+ //
1056
+ // `skipSizes`: array of size names to exclude. Applied
1057
+ // after `sizes` (or after the full size list when `sizes`
1058
+ // is not given). Common use: `['original']` to skip the
1059
+ // potentially very large original upload.
1060
+ //
1061
+ // Cropped variants, when present on the attachment record,
1062
+ // are also subject to `sizes` and `skipSizes` filtering.
1063
+ //
1064
+ // Returns an array of objects:
1065
+ //
1066
+ // ```js
1067
+ // {
1068
+ // _id: 'abc123',
1069
+ // urls: [
1070
+ // // Sized attachments include a `size` property:
1071
+ // { size: 'full', path: '/attachments/abc-photo.full.jpg' },
1072
+ // { size: 'one-half', path: '/attachments/abc-photo.one-half.jpg' },
1073
+ // // crop variants (same sizes):
1074
+ // { size: 'full', path: '/attachments/abc-photo.10.20.300.400.full.jpg' },
1075
+ // ...
1076
+ // // Non-sized attachments (SVG, office docs) have `path` only:
1077
+ // { path: '/attachments/def-document.pdf' },
1078
+ // ]
1079
+ // }
1080
+ // ```
1081
+ //
1082
+ // `path` is an uploadfs-relative path (no host prefix).
1083
+ async getStaticMetadata({
1084
+ docIds, sizes, skipSizes
1085
+ } = {}) {
1086
+ const criteria = {
1087
+ archived: { $ne: true }
1088
+ };
1089
+ if (Array.isArray(docIds)) {
1090
+ criteria.docIds = { $in: docIds };
1091
+ }
1092
+
1093
+ const allSizeNames = self.imageSizes.map((s) => s.name)
1094
+ .concat([ 'original' ]);
1095
+ let effectiveSizes;
1096
+ if (Array.isArray(sizes) && sizes.length) {
1097
+ effectiveSizes = sizes.filter((s) => allSizeNames.includes(s));
1098
+ } else {
1099
+ effectiveSizes = [ ...allSizeNames ];
1100
+ }
1101
+ if (Array.isArray(skipSizes) && skipSizes.length) {
1102
+ effectiveSizes = effectiveSizes.filter(
1103
+ (s) => !skipSizes.includes(s)
1104
+ );
1105
+ }
1106
+
1107
+ const results = [];
1108
+
1109
+ await self.each(criteria, async (attachment) => {
1110
+ const urls = [];
1111
+ const isSized = self.isSized(attachment);
1112
+
1113
+ if (isSized) {
1114
+ // Sized image: emit one entry per requested size
1115
+ for (const size of effectiveSizes) {
1116
+ urls.push({
1117
+ size,
1118
+ path: self.url(attachment, {
1119
+ uploadfsPath: true,
1120
+ crop: false,
1121
+ size
1122
+ })
1123
+ });
1124
+ }
1125
+ // Crop variants — respect the same size constraints
1126
+ for (const crop of (attachment.crops || [])) {
1127
+ for (const size of effectiveSizes) {
1128
+ urls.push({
1129
+ size,
1130
+ path: self.url(attachment, {
1131
+ uploadfsPath: true,
1132
+ crop,
1133
+ size
1134
+ })
1135
+ });
1136
+ }
1137
+ }
1138
+ } else {
1139
+ // Non-sized (SVG, office docs): path only, no size property
1140
+ urls.push({
1141
+ path: self.url(attachment, { uploadfsPath: true })
1142
+ });
1143
+ }
1144
+
1145
+ if (urls.length) {
1146
+ results.push({
1147
+ _id: attachment._id,
1148
+ urls
1149
+ });
1150
+ }
1151
+ });
1152
+
1153
+ return results;
1154
+ },
885
1155
  // Returns true if, based on the provided attachment object,
886
1156
  // a valid focal point has been specified. Useful to avoid
887
1157
  // the default of `background-position: center center` if
@@ -53,7 +53,8 @@ module.exports = {
53
53
  // a 404 if you request a document you cannot edit.
54
54
  async getOne(req, _id) {
55
55
  _id = self.apos.i18n.inferIdLocaleAndMode(req, _id);
56
- const doc = await self.find(req, { _id }).permission('edit').toObject();
56
+ const aposDocId = _id.split(':')[0];
57
+ const doc = await self.find(req, { $or: [ { _id }, { _id: aposDocId } ] }).permission('edit').toObject();
57
58
  if (!doc) {
58
59
  throw self.apos.error('notfound');
59
60
  }
@@ -1794,11 +1795,13 @@ module.exports = {
1794
1795
 
1795
1796
  // Iterate through the document fields and call the provided handlers
1796
1797
  // for each item of an array, object and relationship field type.
1798
+ // Invoke the `widget` handler for widgets in areas if provided.
1797
1799
  walkByMetaType(doc, handlers) {
1798
1800
  const defaultHandlers = {
1799
1801
  arrayItem: () => {},
1800
1802
  object: () => {},
1801
- relationship: () => {}
1803
+ relationship: () => {},
1804
+ widget: null
1802
1805
  };
1803
1806
 
1804
1807
  handlers = {
@@ -1827,6 +1830,9 @@ module.exports = {
1827
1830
  for (const field of schema) {
1828
1831
  if (field.type === 'area' && doc[field.name] && doc[field.name].items) {
1829
1832
  for (const widget of doc[field.name].items) {
1833
+ if (handlers.widget) {
1834
+ handlers.widget(field, widget);
1835
+ }
1830
1836
  self.walkByMetaType(widget, handlers);
1831
1837
  }
1832
1838
  } else if (field.type === 'array') {
@@ -553,6 +553,9 @@ module.exports = {
553
553
  // in an autocomplete menu. Default behavior is to
554
554
  // return only the `title`, `_id` and `slug` properties.
555
555
  // Removing any of these three is not recommended.
556
+ // `aposDocId` is required for building template filters when static
557
+ // url's are enabled, `type` is required for various features including
558
+ // permissions - do not remove these unless you know what you are doing.
556
559
  //
557
560
  // `query.field` will contain the schema field definition for
558
561
  // the relationship the user is attempting to match titles from.
@@ -564,6 +567,7 @@ module.exports = {
564
567
  title: 1,
565
568
  type: 1,
566
569
  _id: 1,
570
+ aposDocId: 1,
567
571
  _url: 1,
568
572
  slug: 1
569
573
  };
@@ -1648,7 +1652,80 @@ module.exports = {
1648
1652
  name: key,
1649
1653
  ...self.columns[key]
1650
1654
  }));
1655
+ },
1656
+
1657
+ // Returns an object with `metadata` (array of URL metadata
1658
+ // entries) and `attachmentDocIds` (array of full `_id` strings
1659
+ // for docs referenced via relationships to types with
1660
+ // attachment fields).
1661
+ //
1662
+ // When `options.attachments` is truthy, each document is also
1663
+ // inspected for attachment references via
1664
+ // `attachment.collectUsedDocIds()`, otherwise `attachmentDocIds`
1665
+ // is returned as an empty array.
1666
+ async getAllUrlMetadata(req, { attachments = false } = {}) {
1667
+ const result = {
1668
+ metadata: [],
1669
+ attachmentDocIds: []
1670
+ };
1671
+ let skip = 0;
1672
+ let docs = [];
1673
+
1674
+ do {
1675
+ // Paginate through 100 at a time to avoid exhausting
1676
+ // memory
1677
+ docs = await self.getUrlMetadataQuery(req)
1678
+ .skip(skip)
1679
+ .limit(100)
1680
+ .toArray();
1681
+ await Promise.all(docs.map(async doc => {
1682
+ result.metadata.push(...await self.getUrlMetadata(req, doc));
1683
+ if (attachments) {
1684
+ result.attachmentDocIds.push(
1685
+ ...self.apos.attachment.collectUsedDocIds(req, doc)
1686
+ );
1687
+ }
1688
+ }));
1689
+ skip += docs.length;
1690
+ } while (docs.length > 0);
1691
+
1692
+ return result;
1693
+ },
1694
+
1695
+ // Used to build sitemaps and assist in static site builds. Extend to
1696
+ // return all URLs that provide views of this document and
1697
+ // should be included in sitemaps and static builds. You may
1698
+ // use `async` when extending
1699
+ getUrlMetadata(req, doc) {
1700
+ if (!doc._url) {
1701
+ return [];
1702
+ }
1703
+ return [
1704
+ {
1705
+ url: doc._url,
1706
+ type: doc.type,
1707
+ aposDocId: doc.aposDocId,
1708
+ i18nId: doc.aposDocId,
1709
+ _id: doc._id,
1710
+ // For legacy reasons. Google 100% ignores this
1711
+ changefreq: 'daily',
1712
+ // For legacy reasons. Google 100% ignores this
1713
+ priority: 1.0
1714
+ }
1715
+ ];
1716
+ },
1717
+
1718
+ // Extend to change the query used when fetching documents of this type
1719
+ // for purposes of building sitemaps and static sites. Should be efficient
1720
+ // while still capturing enough information to generate all URLs for
1721
+ // this particular type of document. By default no relationships are fetched
1722
+ // and widget loaders for areas are not run
1723
+ getUrlMetadataQuery(req) {
1724
+ return self.find(req, {})
1725
+ .relationships(false)
1726
+ .areas(false);
1651
1727
  }
1728
+
1652
1729
  };
1653
1730
  },
1654
1731
  extendMethods(self) {
@@ -2815,7 +2892,10 @@ module.exports = {
2815
2892
  const counts = query.get('distinctCounts');
2816
2893
  if (counts && ((typeof counts) === 'object')) {
2817
2894
  for (const result of results) {
2818
- result.count = counts[result.value];
2895
+ // For relationship slug builders the value is a slug, but
2896
+ // distinctCounts is keyed by aposDocId (from idsStorage).
2897
+ // Fall back to aposDocId when the value key yields nothing.
2898
+ result.count = counts[result.value] ?? counts[result.aposDocId];
2819
2899
  }
2820
2900
  }
2821
2901
  return results;
@@ -4,6 +4,7 @@
4
4
  :modal="modal"
5
5
  :modal-title="modalTitle"
6
6
  :modal-data="modalData"
7
+ :graph-key="graphKey"
7
8
  @inactive="modal.active = false"
8
9
  @show-modal="modal.showModal = true"
9
10
  @esc="confirmAndCancel"
@@ -134,6 +135,7 @@
134
135
 
135
136
  <script>
136
137
  import { klona } from 'klona';
138
+ import { computed } from 'vue';
137
139
  import { mapActions } from 'pinia';
138
140
  import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixin';
139
141
  import AposModalTabsMixin from 'Modules/@apostrophecms/modal/mixins/AposModalTabsMixin';
@@ -144,6 +146,7 @@ import AposAdvisoryLockMixin from 'Modules/@apostrophecms/ui/mixins/AposAdvisory
144
146
  import AposDocErrorsMixin from 'Modules/@apostrophecms/modal/mixins/AposDocErrorsMixin';
145
147
  import { detectDocChange } from 'Modules/@apostrophecms/schema/lib/detectChange';
146
148
  import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal';
149
+ import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph.js';
147
150
 
148
151
  export default {
149
152
  name: 'AposDocEditor',
@@ -156,10 +159,11 @@ export default {
156
159
  AposArchiveMixin,
157
160
  AposDocErrorsMixin
158
161
  ],
159
- provide () {
162
+ provide() {
160
163
  return {
161
164
  originalDoc: this.originalDoc,
162
- liveOriginalDoc: this.docFields
165
+ liveOriginalDoc: this.docFields,
166
+ aposGraphKey: computed(() => this.graphKey)
163
167
  };
164
168
  },
165
169
  props: {
@@ -322,6 +326,11 @@ export default {
322
326
  type: this.$t(this.moduleOptions.label)
323
327
  };
324
328
  },
329
+ graphKey() {
330
+ return this.modalData?.id && this.currentId
331
+ ? `${this.modalData.id}:${this.currentId}`
332
+ : null;
333
+ },
325
334
  saveLabel() {
326
335
  if (this.restoreOnly) {
327
336
  return 'apostrophe:restore';
@@ -417,9 +426,16 @@ export default {
417
426
  },
418
427
  unmounted() {
419
428
  apos.bus.$off('content-changed', this.onContentChanged);
429
+ // Destroy the widget graph for this modal's editing context
430
+ if (this.graphKey) {
431
+ this.storeDestroyGraph(this.graphKey);
432
+ }
420
433
  },
421
434
  methods: {
422
435
  ...mapActions(useModalStore, [ 'updateModalData' ]),
436
+ ...mapActions(useWidgetGraphStore, {
437
+ storeDestroyGraph: 'destroyGraph'
438
+ }),
423
439
  async instantiateExistingDoc() {
424
440
  await this.loadDoc();
425
441
  this.evaluateConditions();
@@ -272,6 +272,11 @@ module.exports = {
272
272
  return res.status(403).send('forbidden');
273
273
  }
274
274
  req.aposExternalFront = true;
275
+ if (req.headers['x-apos-static-base-url'] === '1') {
276
+ // Downstream code (page.getBaseUrl) checks this to decide
277
+ // which base URL to use for this request.
278
+ self.applyStaticBuildHeaders(req);
279
+ }
275
280
  res.redirect = function(...args) {
276
281
  // The external front end needs to issue the actual redirect,
277
282
  // not us
@@ -290,6 +295,20 @@ module.exports = {
290
295
  };
291
296
  return next();
292
297
  },
298
+ // Allow direct API calls (without externalFrontKey) to opt into
299
+ // static-build URL behavior by sending x-apos-static-base-url: 1.
300
+ // This is harmless — it only changes how URLs are built in the
301
+ // response, granting no elevated permissions.
302
+ staticBuildHeader: {
303
+ url: '/api/v1',
304
+ middleware(req, res, next) {
305
+ // Only act if not already set by externalFront middleware
306
+ if (!req.aposStaticBuild && req.headers['x-apos-static-base-url'] === '1') {
307
+ self.applyStaticBuildHeaders(req);
308
+ }
309
+ return next();
310
+ }
311
+ },
293
312
  attachUtilityMethods(req, res, next) {
294
313
  // We apply the super pattern variously to res.redirect,
295
314
  // make sure the original version is always available
@@ -385,7 +404,7 @@ module.exports = {
385
404
  // for the token to be usable to log in.
386
405
  $or: [
387
406
  { requirementsToVerify: { $exists: false } },
388
- { requirementsToVerify: { $ne: [] } }
407
+ { requirementsToVerify: { $size: 0 } }
389
408
  ]
390
409
  });
391
410
  return bearer && bearer.userId;
@@ -460,6 +479,16 @@ module.exports = {
460
479
  }
461
480
  },
462
481
 
482
+ applyStaticBuildHeaders(req) {
483
+ if (req.aposStaticBuild) {
484
+ return;
485
+ }
486
+ req.aposStaticBuild = true;
487
+ if (self.apos.staticBaseUrl) {
488
+ req.staticBaseUrl = self.apos.staticBaseUrl;
489
+ }
490
+ },
491
+
463
492
  logAllRoutes() {
464
493
  const superUse = self.apos.app.use.bind(self.apos.app);
465
494
  const methods = [ 'get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all' ];
@@ -1,5 +1,3 @@
1
- const _ = require('lodash');
2
-
3
1
  // A subclass of `@apostrophecms/piece-type`, `@apostrophecms/file` establishes
4
2
  // a library of uploaded files, which may be of any type acceptable to the
5
3
  // [@apostrophecms/attachment](../@apostrophecms/attachment/index.html) module.
@@ -8,6 +6,8 @@ const _ = require('lodash');
8
6
  // module provides a simple way to add downloadable PDFs and the like to a
9
7
  // website, and to manage a library of them for reuse.
10
8
 
9
+ const streamProxy = require('../../../lib/stream-proxy.js');
10
+
11
11
  module.exports = {
12
12
  extend: '@apostrophecms/piece-type',
13
13
  options: {
@@ -26,7 +26,9 @@ module.exports = {
26
26
  // Files should by default be considered "related documents" when localizing
27
27
  // another document that references them
28
28
  relatedDocument: true,
29
- relationshipSuggestionIcon: 'file-document-icon'
29
+ relationshipSuggestionIcon: 'file-document-icon',
30
+ prettyUrls: false,
31
+ prettyUrlDir: '/files'
30
32
  },
31
33
  fields: {
32
34
  remove: [ 'visibility' ],
@@ -92,10 +94,73 @@ module.exports = {
92
94
  },
93
95
  methods(self) {
94
96
  return {
97
+ // File docs are attachment containers themselves — their
98
+ // attachments are discovered via relationship walking from
99
+ // the content docs that reference them. Iterating all
100
+ // published files here would make the "used" scope
101
+ // equivalent to "all", defeating scoped attachment builds.
102
+ async getAllUrlMetadata() {
103
+ return {
104
+ metadata: [],
105
+ attachmentDocIds: []
106
+ };
107
+ },
95
108
  addUrls(req, files) {
96
- _.each(files, function (file) {
97
- file._url = self.apos.attachment.url(file.attachment);
98
- });
109
+ for (const file of files) {
110
+ // Watch out for projections with no attachment property
111
+ // (the slug-taken route does that)
112
+ if (self.options.prettyUrls && file.attachment) {
113
+ const { extension } = file.attachment;
114
+ const baseUrl = self.apos.url.getBaseUrl(req, { prefix: true });
115
+ file._url = `${baseUrl}${self.options.prettyUrlDir}/${file.slug.replace(self.options.slugPrefix || '', '')}.${extension}`;
116
+ file.attachment._prettyUrl = file._url;
117
+ } else {
118
+ file._url = self.apos.attachment.url(file.attachment);
119
+ }
120
+ }
121
+ }
122
+ };
123
+ },
124
+ routes(self) {
125
+ if (!self.options.prettyUrls) {
126
+ return;
127
+ }
128
+ return {
129
+ get: {
130
+ async [`${self.options.prettyUrlDir}/*`](req, res) {
131
+ try {
132
+ const matches = (req.params[0] || '').match(/^([^.]+)\.\w+$/);
133
+ if (!matches) {
134
+ return res.status(400).send('invalid');
135
+ }
136
+ const [ , slug ] = matches;
137
+ if (slug.includes('..') || slug.includes('/')) {
138
+ return res.status(403).send('forbidden');
139
+ }
140
+ const file = await self.find(req, {
141
+ slug: `${self.options.slugPrefix}${slug}`
142
+ }).toObject();
143
+ if (!file) {
144
+ return res.status(404).send('not found');
145
+ }
146
+
147
+ // Determine the normal, "ugly" URL and stream the
148
+ // response from it, passing on the most important
149
+ // headers. Supporting range requests, which PDF viewers
150
+ // like to use for pagination, was considered but
151
+ // the potential interacts with gzip encoding are complex
152
+ // and we currently do use it by default with s3 for PDFs.
153
+ // Viewers will still display the document after
154
+ // completing the download
155
+ const uglyUrl = self.apos.attachment.url(file.attachment, {
156
+ prettyUrl: false
157
+ });
158
+ return await streamProxy(req, uglyUrl, { error: self.apos.util.error });
159
+ } catch (e) {
160
+ self.apos.util.error('Error in pretty URL route:', e);
161
+ return res.status(500).send('error');
162
+ }
163
+ }
99
164
  }
100
165
  };
101
166
  }
@@ -222,7 +222,26 @@ module.exports = {
222
222
  const doc = localizations.find(doc => doc.aposLocale.split(':')[0] === name);
223
223
  if (doc && self.apos.permission.can(req, 'view', doc)) {
224
224
  doc.available = true;
225
- doc._url = `${self.apos.prefix}${manager.action}/${context._id}/locale/${name}`;
225
+ // WARNING: the `addUrls` call below has a serious
226
+ // performance impact (extra DB queries per locale).
227
+ // Keep this gated for static builds only.
228
+ if (self.apos.url.isExternalFront(req) && self.apos.url.options.static) {
229
+ // Static builds can't follow the API redirect route
230
+ // (there is no server to handle it), so compute the
231
+ // real URL using the same mechanisms the query builders
232
+ // would.
233
+ if (self.apos.page.isPage(doc)) {
234
+ doc._url = `${localeReq.prefix}${doc.slug}`;
235
+ } else if (manager.addUrls) {
236
+ await manager.addUrls(localeReq, [ doc ]);
237
+ }
238
+ }
239
+ // Fall back to the API redirect route when no real URL
240
+ // was resolved (e.g. traditional Nunjucks frontend, non static
241
+ // external frontend).
242
+ if (!doc._url) {
243
+ doc._url = `${self.apos.prefix}${manager.action}/${context._id}/locale/${name}`;
244
+ }
226
245
  if (doc._id === context._id) {
227
246
  doc.current = true;
228
247
  }