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.
- package/CHANGELOG.md +35 -0
- package/index.js +3 -0
- package/lib/stream-proxy.js +49 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +2 -11
- package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +38 -6
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +12 -1
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +111 -41
- package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +1 -0
- package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +22 -10
- package/modules/@apostrophecms/area/ui/apos/logic/AposAreaEditor.js +40 -0
- package/modules/@apostrophecms/asset/index.js +3 -2
- package/modules/@apostrophecms/attachment/index.js +270 -0
- package/modules/@apostrophecms/doc/index.js +8 -2
- package/modules/@apostrophecms/doc-type/index.js +81 -1
- package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +18 -2
- package/modules/@apostrophecms/express/index.js +30 -1
- package/modules/@apostrophecms/file/index.js +71 -6
- package/modules/@apostrophecms/i18n/index.js +20 -1
- package/modules/@apostrophecms/image/index.js +11 -0
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +31 -6
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +12 -10
- package/modules/@apostrophecms/login/index.js +43 -11
- package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +2 -1
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +5 -0
- package/modules/@apostrophecms/page/index.js +9 -11
- package/modules/@apostrophecms/page-type/index.js +6 -1
- package/modules/@apostrophecms/piece-page-type/index.js +100 -13
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposImageControlDialog.vue +1 -0
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +28 -12
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +1 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +1 -1
- package/modules/@apostrophecms/styles/lib/apiRoutes.js +25 -5
- package/modules/@apostrophecms/styles/lib/handlers.js +19 -0
- package/modules/@apostrophecms/styles/lib/methods.js +35 -12
- package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +7 -2
- package/modules/@apostrophecms/task/index.js +9 -1
- package/modules/@apostrophecms/template/views/outerLayoutBase.html +3 -0
- package/modules/@apostrophecms/ui/index.js +2 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +5 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +5 -0
- package/modules/@apostrophecms/ui/ui/apos/lib/vue.js +2 -0
- package/modules/@apostrophecms/ui/ui/apos/stores/widget.js +12 -7
- package/modules/@apostrophecms/ui/ui/apos/stores/widgetGraph.js +461 -0
- package/modules/@apostrophecms/ui/ui/apos/universal/graph.js +452 -0
- package/modules/@apostrophecms/ui/ui/apos/universal/widgetGraph.js +10 -0
- package/modules/@apostrophecms/uploadfs/index.js +15 -1
- package/modules/@apostrophecms/url/index.js +419 -1
- package/package.json +6 -6
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
- package/test/external-front.js +1 -0
- package/test/files.js +135 -0
- package/test/login-requirements.js +145 -3
- package/test/static-build.js +2701 -0
- 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
|
|
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
|
-
|
|
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: { $
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
}
|