Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/apostrophe-astro/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 1.12.0

### Adds

- Editors can now control the layout-widget gap through the styles system, both site-wide via a global `layoutGap` preset and per widget via a `gap` styles field. New Layout widget option `className` allows for additional CSS class names to be added to the widget Grid container.
- Log aposResponse errors server side in the Astro process. Thanks to [Harouna Traore](https://github.com/haroun).

## 1.11.0 (2026-04-15)

### Adds
Expand Down
5 changes: 4 additions & 1 deletion packages/apostrophe-astro/lib/aposResponse.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default async function aposResponse(req) {

const aposUrl = new URL(aposHost + pathname);
aposUrl.search = url.search;

// Prepare headers, excluding any specified in config
const requestHeaders = {};
for (const [name, value] of req.headers) {
Expand Down Expand Up @@ -111,6 +111,7 @@ export default async function aposResponse(req) {
}
// Skip unknown encodings silently
} catch (decompressError) {
console.error(decompressError);
// If decompression fails, return original response
return new Response(new Uint8Array(bodyArrayBuffer), {
...rest,
Expand All @@ -133,10 +134,12 @@ export default async function aposResponse(req) {
headers: responseHeaders
});
} catch (bodyError) {
console.error(bodyError);
// If we can't process the body, fall back to the original response
return new Response(res.body, { ...rest, status: statusCode, headers: responseHeaders });
}
} catch (error) {
console.error(error);
// Handle any unexpected errors
return new Response(`Server error: ${error.message}`, { status: 500 });
}
Expand Down
2 changes: 1 addition & 1 deletion packages/apostrophe-astro/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@apostrophecms/apostrophe-astro",
"version": "1.11.0",
"version": "1.12.0",
"type": "module",
"description": "Apostrophe integration for Astro",
"repository": {
Expand Down
32 changes: 27 additions & 5 deletions packages/apostrophe-astro/widgets/LayoutWidget.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,39 @@
const { widget, options } = Astro.props;
import AposArea from "../components/AposArea.astro";
import LayoutColumn from "./LayoutColumn.astro";

const gapMeta = widget._options || {};
const resolvedGap = options._gap ?? gapMeta._gap ?? null;
const gapHasGlobal = options._gapHasGlobal ?? gapMeta._gapHasGlobal ?? false;

// Resolve the inline `--grid-gap` value
const hasWidgetGap = resolvedGap != null;
const omitGridGap = !hasWidgetGap && gapHasGlobal;
const gridGap = hasWidgetGap
? resolvedGap
: omitGridGap
? undefined
: options.gap || "0";

// Mirror the Nunjucks `parentOptionsForArea` helper so the in-place
// layout editor (AposAreaLayoutEditor) sees the same `gap` signal in
// its `data-parent-options` JSON as it would on the Nunjucks side:
// - widget value present → carries through (string with unit).
// - widget value absent + global enabled → `gap: null` (signal omit).
// - otherwise → no `gap` key (use the static module default / BC).
const parentOptions = {
...options,
widgetId: widget._id,
...(hasWidgetGap ? { gap: resolvedGap } : omitGridGap ? { gap: null } : {}),
};
---

<AposArea
area={widget.columns}
aposClassName="layout-widget"
aposStyle={{
"--grid-columns": options.columns,
"--grid-gap": options.gap || "0",
...(gridGap !== undefined ? { "--grid-gap": gridGap } : {}),
"--grid-rows": "auto",
"--mobile-grid-rows": "auto",
"--tablet-grid-rows": "auto",
Expand All @@ -20,9 +45,6 @@ import LayoutColumn from "./LayoutColumn.astro";
"data-tablet-auto": true,
"data-mobile-auto": true,
}}
aposParentOptions={{
...options,
widgetId: widget._id,
}}
aposParentOptions={parentOptions}
widgetComponent={LayoutColumn}
/>
34 changes: 34 additions & 0 deletions packages/apostrophe/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## 4.30.0

### Adds

- Layout widget gap is now controllable through the styles system, both site-wide via a global `layoutGap` preset and per widget via a `gap` styles field. A new `className` option allows additional CSS classes to be added to the widget grid container.

### Fixes

- Fixed layout widget not regaining full focus when switching back to Edit content mode.
- Fixed illegal HTML `id` attribute values generated by the admin UI.
- Fixed orderable table array items dragging the entire floating window.
- Fixed keyboard shortcuts for widget operations (copy, cut, paste, duplicate, remove) blocking the browser's native clipboard behavior when no widget was focused. Previously, selecting and copying text while logged in was intercepted unconditionally by the admin UI.
- Removed duplicate `<meta charset>` tag from `outerLayoutBase.html` and standardized charset to `utf-8`.
- Updated `apostrophe` and `oembetter` to remove oembed services that no longer support public access, eliminating them as a potential future XSS vector. New `minimumAllowlist` and `minimumEndpoints` options on `@apostrophecms/oembed` allow developers to prune the list further.

### Security

- **Password reset base URL requirement:** The password reset feature now refuses to operate unless `baseUrl` or `APOS_BASE_URL` is set, preventing a vulnerability where ApostropheCMS could be convinced to send emails with links to attacker-controlled sites. Only affects projects with `passwordReset: true` on the login module. Thanks to [SPIDY](https://github.com/Mujahidkhan525) for reporting.
- **XSS via full name field:** A malicious full name containing HTML was executed in the page title tooltip in the admin bar, posing an XSS risk to other users. All multi-user projects should update promptly. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting.
- **XSS via image widget link URL:** Users with editing privileges could trigger arbitrary JavaScript via a `javascript:` URL in the image widget's link URL field. A migration is included to strip any such URLs already in the database. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting.
- **SSRF via rich text HTML import:** The rich text widget's HTML import feature no longer fetches images from arbitrary hosts, which could be used to probe internal networks or exfiltrate internal images. Configure `imageImportAllowedHostnames` on `@apostrophecms/rich-text-widget` to opt in. Thanks to [Yiğit Şengezer](https://github.com/yigitsengezer) and [Sainithin0309](https://github.com/Sainithin0309) for reporting.
- **the xmp tag could be used to pass forbidden markup through sanitize-html**, even when xmp itself. This was fixed in `sanitize-html` and the dependency was bumped. Thanks to [Vincenzo Turturro](https://github.com/sushi-gif) for reporting the vulnerability.
- **the `linkHref` field of image widgets was an XSS vulnerability** because it did not use the `url` field type. This means that a user with editing privileges could potentially carry out XSS. In addition, we have updated the `launder` module to sanitize URLs more robustly for the `url` field type, and bumped that dependency. Also, a database migration is included to clean any XSS attacks that could be present in existing links. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue.

### Accessibility

- Corrected ARIA semantics on the top admin navigation bar.
- Improved the document context title (admin bar middle group) and the underlying `AposContextMenu` machinery.
- Improved the locale switcher (`AposLocalePicker`).
- The Recently Edited Documents tray icon now exposes its action via `aria-label`.
- Fixed `.apos-sr-only` so screen-reader-only content is correctly exposed to the accessibility tree.
- Icon-only context-utility buttons in the admin bar tray (e.g. the global settings cog) now expose their action via `aria-label`.


## 4.29.0 (2026-04-15)

### Adds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
<nav
ref="adminBar"
:class="classes"
role="menubar"
aria-label="Apostrophe Admin Bar"
>
<div class="apos-admin-bar__row">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
<template>
<ol
class="apos-admin-bar__items"
role="menu"
>
<ol class="apos-admin-bar__items">
<li
v-if="pageTree"
class="apos-admin-bar__item"
Expand All @@ -12,7 +9,6 @@
label="apostrophe:pages"
class="apos-admin-bar__btn"
:modifiers="['no-motion']"
role="menuitem"
action-test-label="page-manager-button"
@click="emitEvent({ action: '@apostrophecms/page:manager' })"
/>
Expand All @@ -33,7 +29,6 @@
class: 'apos-admin-bar__btn',
type: 'subtle'
}"
role="menuitem"
@item-clicked="emitEvent"
/>
<Component
Expand All @@ -44,7 +39,6 @@
:modifiers="['no-motion']"
class="apos-admin-bar__btn"
:action-test-label="`${item.name}-manager-button`"
role="menuitem"
@click="emitEvent(item)"
/>
</li>
Expand All @@ -62,7 +56,6 @@
type: 'primary',
modifiers: ['round', 'no-motion']
}"
role="menuitem"
@item-clicked="emitEvent"
/>
</li>
Expand All @@ -89,6 +82,7 @@
:label="item.label"
:action="item.action"
:state="trayItemState[item.name] ? [ 'active' ] : []"
:attrs="trayItemAttrs(item)"
@click="emitEvent(item)"
/>
</template>
Expand Down Expand Up @@ -185,6 +179,29 @@ export default {
} else {
return item.options.tooltip;
}
},
// Tray utility buttons render icon-only, so the visible label
// (e.g. "Global Content") is sr-only and doesn't describe what the
// button does. Make them accessible by providing an aria-label based on
// the tooltip content.
trayItemAttrs(item) {
const tooltip = item.options?.tooltip;
let key = null;
if (item.options?.toggle) {
if (this.trayItemState[item.name] && tooltip?.deactivate) {
key = tooltip.deactivate;
} else if (tooltip?.activate) {
key = tooltip.activate;
}
} else if (typeof tooltip === 'string') {
key = tooltip;
} else if (tooltip && typeof tooltip.content === 'string') {
key = tooltip.content;
}
if (!key) {
return {};
}
return { 'aria-label': this.$t(key) };
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
:label="screen.label"
:tooltip="$t(screen.label)"
:title="$t(screen.label)"
:attrs="shortcutAttrs(screen)"
:icon="screen.icon"
:icon-only="true"
type="subtle"
Expand All @@ -36,6 +37,7 @@
:active-item="mode"
:center-on-icon="true"
menu-placement="bottom-end"
:dialog-label="'apostrophe:breakpointPreviewSelectMenu'"
@item-clicked="selectBreakpoint"
/>
<Transition>
Expand Down Expand Up @@ -320,6 +322,13 @@ export default {
},
setShowDropdown() {
this.showDropdown = Object.values(this.screens).some(({ shortcut }) => !shortcut);
},
shortcutAttrs(screen) {
return {
'aria-label': this.$t('apostrophe:breakpointPreviewShortcut', {
breakpoint: this.$t(screen.label)
})
};
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
:disabled="hasCustomUi || isUnpublished"
:center-on-icon="true"
menu-placement="bottom-end"
:dialog-label="'apostrophe:publicationStatusMenu'"
:trigger-aria-label="draftTriggerAriaLabel"
@item-clicked="switchDraftMode"
/>
<AposLabel
Expand All @@ -56,6 +58,15 @@
<script>
import dayjs from 'dayjs';

function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

export default {
name: 'TheAposContextTitle',
props: {
Expand All @@ -78,8 +89,8 @@ export default {
if (this.context.updatedBy) {
const editor = this.context.updatedBy;
editorLabel = '';
editorLabel += editor.title ? `${editor.title} ` : '';
editorLabel += editor.username ? `(${editor.username})` : '';
editorLabel += editor.title ? `${escapeHtml(editor.title)} ` : '';
editorLabel += editor.username ? `(${escapeHtml(editor.username)})` : '';
}
return editorLabel;
},
Expand All @@ -91,6 +102,13 @@ export default {
type: 'quiet'
};
},
draftTriggerAriaLabel() {
return this.$t('apostrophe:publicationStatusTrigger', {
status: this.$t(
this.draftMode === 'draft' ? 'apostrophe:draft' : 'apostrophe:published'
)
});
},
isUnpublished() {
return !this.context.lastPublishedAt;
},
Expand Down
15 changes: 10 additions & 5 deletions packages/apostrophe/modules/@apostrophecms/area/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,39 +19,44 @@ module.exports = {
action: {
type: 'command-menu-area-cut-widget'
},
shortcut: 'Ctrl+X Meta+X'
shortcut: 'Ctrl+X Meta+X',
requireWidgetFocus: true
},
[`${self.__meta.name}:copy-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetCopy',
action: {
type: 'command-menu-area-copy-widget'
},
shortcut: 'Ctrl+C Meta+C'
shortcut: 'Ctrl+C Meta+C',
requireWidgetFocus: true
},
[`${self.__meta.name}:paste-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetPaste',
action: {
type: 'command-menu-area-paste-widget'
},
shortcut: 'Ctrl+V Meta+V'
shortcut: 'Ctrl+V Meta+V',
requireWidgetFocus: true
},
[`${self.__meta.name}:duplicate-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetDuplicate',
action: {
type: 'command-menu-area-duplicate-widget'
},
shortcut: 'Ctrl+Shift+D Meta+Shift+D'
shortcut: 'Ctrl+Shift+D Meta+Shift+D',
requireWidgetFocus: true
},
[`${self.__meta.name}:remove-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetRemove',
action: {
type: 'command-menu-area-remove-widget'
},
shortcut: 'Backspace'
shortcut: 'Backspace',
requireWidgetFocus: true
}
},
modal: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ export default {
apos.bus.$on('widget-focus-parent', this.focusParent);
apos.bus.$on('context-menu-toggled', this.getFocusForMenu);
apos.bus.$on('suppress-focused-widget-controls', this.doSuppressWidgetControls);
apos.bus.$on('clear-focused-widget-control-suppression', this.clearSuppressionFlags);

this.breadcrumbs.$lastEl = this.$el;

Expand Down Expand Up @@ -573,6 +574,7 @@ export default {
// Remove the focus parent listener when unmounted
apos.bus.$off('widget-focus-parent', this.focusParent);
apos.bus.$off('suppress-focused-widget-controls', this.doSuppressWidgetControls);
apos.bus.$off('clear-focused-widget-control-suppression', this.clearSuppressionFlags);
window.removeEventListener('scroll', this.stickyControlsScroll);
window.removeEventListener('resize', this.stickyControlsResize);
this.unregisterFromGraph();
Expand Down
Loading
Loading