Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc49fe0687 | ||
|
|
419382d25d | ||
|
|
8935b81f12 | ||
|
|
ec581ce53e | ||
|
|
81d8465a79 | ||
|
|
0f3b506604 | ||
|
|
1b84547211 | ||
|
|
dafc8ace3c | ||
|
|
2c419cc4fe | ||
|
|
90acf181f3 | ||
|
|
54af07f2af | ||
|
|
68f1fabc4b |
+19
-1
@@ -84,6 +84,23 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# volume there (see docker-compose.yml).
|
# volume there (see docker-compose.yml).
|
||||||
# ADMIN_DATA_DIR=./data/admin
|
# ADMIN_DATA_DIR=./data/admin
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Anonymous Telemetry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
||||||
|
# version, platform, bucketed account counts, and feature toggles only. See
|
||||||
|
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||||
|
#
|
||||||
|
# Disable telemetry entirely (overrides the admin UI):
|
||||||
|
# BULWARK_TELEMETRY=off
|
||||||
|
|
||||||
|
# Directory for telemetry state: instance id, consent, login HMACs
|
||||||
|
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||||
|
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
|
||||||
|
# so the instance id and consent choice survive upgrades.
|
||||||
|
# TELEMETRY_DATA_DIR=./data/telemetry
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Server Listen Address
|
# Server Listen Address
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -191,7 +208,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# URL of the BulwarkMail extension directory for the admin marketplace.
|
# URL of the BulwarkMail extension directory for the admin marketplace.
|
||||||
# Set this to enable browsing and installing plugins/themes from the directory.
|
# Defaults to https://extensions.bulwarkmail.org. Override only if you run
|
||||||
|
# your own directory (e.g. http://localhost:3001 for local development).
|
||||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+21
-2
@@ -1,11 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.5.3 (2026-04-28)
|
||||||
|
|
||||||
|
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
|
||||||
|
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
|
||||||
|
- **Plugins**: Theme API v2 with token compiler and skin slot
|
||||||
|
- **Plugins**: Extension preview page and detailed extension info API
|
||||||
|
- **Calendar**: Right-click context menu on empty calendar space
|
||||||
|
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
|
||||||
|
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
|
||||||
|
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
|
||||||
|
|
||||||
## 1.5.2 (2026-04-27)
|
## 1.5.2 (2026-04-27)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
|
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
|
||||||
- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
||||||
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
||||||
- **i18n**: Czech language support
|
- **i18n**: Czech language support
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
|
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +53,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
|
|||||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||||
|
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||||
@@ -104,6 +105,7 @@ export default function CalendarPage() {
|
|||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
|
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
|
||||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
@@ -326,11 +328,12 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
}, [setSelectedDate]);
|
}, [setSelectedDate]);
|
||||||
|
|
||||||
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
|
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
const d = date || selectedDate;
|
const d = date || selectedDate;
|
||||||
setDefaultModalDate(d);
|
setDefaultModalDate(d);
|
||||||
setDefaultModalEndDate(endDate);
|
setDefaultModalEndDate(endDate);
|
||||||
|
setDefaultModalAllDay(allDay ?? false);
|
||||||
setSelectedDate(d);
|
setSelectedDate(d);
|
||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, [selectedDate, setSelectedDate]);
|
}, [selectedDate, setSelectedDate]);
|
||||||
@@ -395,6 +398,21 @@ export default function CalendarPage() {
|
|||||||
openEventContextMenu(e, event);
|
openEventContextMenu(e, event);
|
||||||
}, [closeDetail, openEventContextMenu]);
|
}, [closeDetail, openEventContextMenu]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
contextMenu: emptyContextMenu,
|
||||||
|
openContextMenu: openEmptyContextMenu,
|
||||||
|
closeContextMenu: closeEmptyContextMenu,
|
||||||
|
menuRef: emptyContextMenuRef,
|
||||||
|
} = useContextMenu<{ date: Date; hour?: number; allDayArea?: boolean }>();
|
||||||
|
|
||||||
|
const handleContextMenuEmpty = useCallback(
|
||||||
|
(e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => {
|
||||||
|
closeDetail();
|
||||||
|
openEmptyContextMenu(e, { date, hour, allDayArea });
|
||||||
|
},
|
||||||
|
[closeDetail, openEmptyContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
if (calendarHoverPreview === 'off') return;
|
if (calendarHoverPreview === 'off') return;
|
||||||
@@ -961,6 +979,7 @@ export default function CalendarPage() {
|
|||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
onContextMenuEvent={handleContextMenuEvent}
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -978,6 +997,7 @@ export default function CalendarPage() {
|
|||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
onContextMenuEvent={handleContextMenuEvent}
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
@@ -997,6 +1017,7 @@ export default function CalendarPage() {
|
|||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
onContextMenuEvent={handleContextMenuEvent}
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -1211,12 +1232,13 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultAllDay={defaultModalAllDay}
|
||||||
defaultCalendarId={defaultCalendarIdForCreate}
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||||
onPreviewChange={setPendingPreview}
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
@@ -1282,6 +1304,38 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{emptyContextMenu.data && (() => {
|
||||||
|
const { date, hour } = emptyContextMenu.data;
|
||||||
|
return (
|
||||||
|
<EmptySpaceContextMenu
|
||||||
|
position={emptyContextMenu.position}
|
||||||
|
isOpen={emptyContextMenu.isOpen}
|
||||||
|
onClose={closeEmptyContextMenu}
|
||||||
|
menuRef={emptyContextMenuRef}
|
||||||
|
onNewEvent={() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (typeof hour === "number") {
|
||||||
|
d.setHours(hour, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||||
|
}
|
||||||
|
openCreateModal(d);
|
||||||
|
}}
|
||||||
|
onNewAllDayEvent={() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
openCreateModal(d, undefined, true);
|
||||||
|
}}
|
||||||
|
onNewTask={enableCalendarTasks ? () => {
|
||||||
|
setEditTask(null);
|
||||||
|
setShowTaskModal(true);
|
||||||
|
} : undefined}
|
||||||
|
onGoToToday={goToToday}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
<EventDetailPopover
|
<EventDetailPopover
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
@@ -1308,12 +1362,13 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultAllDay={defaultModalAllDay}
|
||||||
defaultCalendarId={defaultCalendarIdForCreate}
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={true}
|
isMobile={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+68
-12
@@ -171,6 +171,11 @@ export default function Home() {
|
|||||||
createMailbox,
|
createMailbox,
|
||||||
renameMailbox,
|
renameMailbox,
|
||||||
deleteMailbox,
|
deleteMailbox,
|
||||||
|
batchDelete,
|
||||||
|
batchArchive,
|
||||||
|
batchMarkAsRead,
|
||||||
|
batchMarkAsSpam,
|
||||||
|
batchUndoSpam,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
@@ -353,27 +358,77 @@ export default function Home() {
|
|||||||
onToggleStar: () => {
|
onToggleStar: () => {
|
||||||
if (selectedEmail) handleToggleStar();
|
if (selectedEmail) handleToggleStar();
|
||||||
},
|
},
|
||||||
onArchive: () => {
|
onArchive: async () => {
|
||||||
if (selectedEmail) handleArchive();
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
|
try {
|
||||||
|
await batchArchive(client);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch archive:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
|
handleArchive();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDelete: () => {
|
onDelete: async () => {
|
||||||
if (selectedEmail) handleDelete();
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
|
const isInTrash = currentMailbox?.role === 'trash';
|
||||||
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
|
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||||||
|
const permanent = isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: permanent
|
||||||
|
? t('email_list.permanent_delete_confirm_title')
|
||||||
|
: t('email_list.batch_actions.delete_confirm_title'),
|
||||||
|
message: permanent
|
||||||
|
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
|
||||||
|
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
|
||||||
|
confirmText: permanent
|
||||||
|
? t('email_list.permanent_delete')
|
||||||
|
: t('email_list.batch_actions.delete'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
await batchDelete(client, permanent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch delete:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
|
handleDelete();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onMarkAsUnread: async () => {
|
onMarkAsUnread: async () => {
|
||||||
if (selectedEmail && client) {
|
if (!client) return;
|
||||||
|
if (selectedEmailIds.size > 0) {
|
||||||
|
await batchMarkAsRead(client, false);
|
||||||
|
} else if (selectedEmail) {
|
||||||
await markAsRead(client, selectedEmail.id, false);
|
await markAsRead(client, selectedEmail.id, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkAsRead: async () => {
|
onMarkAsRead: async () => {
|
||||||
if (selectedEmail && client) {
|
if (!client) return;
|
||||||
|
if (selectedEmailIds.size > 0) {
|
||||||
|
await batchMarkAsRead(client, true);
|
||||||
|
} else if (selectedEmail) {
|
||||||
await markAsRead(client, selectedEmail.id, true);
|
await markAsRead(client, selectedEmail.id, true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleSpam: () => {
|
onToggleSpam: async () => {
|
||||||
if (selectedEmail) {
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
// Check if we're in junk folder
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
const isInJunk = currentMailbox?.role === 'junk';
|
const ids = Array.from(selectedEmailIds);
|
||||||
|
try {
|
||||||
|
if (isInJunk) {
|
||||||
|
await batchUndoSpam(client, ids);
|
||||||
|
} else {
|
||||||
|
await batchMarkAsSpam(client, ids);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch toggle spam:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
if (isInJunk) {
|
if (isInJunk) {
|
||||||
handleUndoSpam();
|
handleUndoSpam();
|
||||||
} else {
|
} else {
|
||||||
@@ -408,13 +463,14 @@ export default function Home() {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]);
|
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
|
||||||
|
|
||||||
// Initialize keyboard shortcuts
|
// Initialize keyboard shortcuts
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: isAuthenticated && !showComposer,
|
enabled: isAuthenticated && !showComposer,
|
||||||
emails,
|
emails,
|
||||||
selectedEmailId: selectedEmail?.id,
|
selectedEmailId: selectedEmail?.id,
|
||||||
|
selectionCount: selectedEmailIds.size,
|
||||||
handlers: keyboardHandlers,
|
handlers: keyboardHandlers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
|
Activity,
|
||||||
Mail,
|
Mail,
|
||||||
Calendar,
|
Calendar,
|
||||||
BookUser,
|
BookUser,
|
||||||
@@ -56,6 +57,7 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
label: 'System',
|
label: 'System',
|
||||||
items: [
|
items: [
|
||||||
|
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
||||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Puzzle,
|
||||||
|
SwatchBook,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
ExternalLink,
|
||||||
|
Shield,
|
||||||
|
AlertTriangle,
|
||||||
|
FileCode,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface PreviewData {
|
||||||
|
extension: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
type: 'plugin' | 'theme';
|
||||||
|
pluginType: string | null;
|
||||||
|
description: string;
|
||||||
|
longDescription: string | null;
|
||||||
|
tags: string[];
|
||||||
|
permissions: string[];
|
||||||
|
totalDownloads: number;
|
||||||
|
featured: boolean;
|
||||||
|
githubRepo: string | null;
|
||||||
|
license: string | null;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
author: {
|
||||||
|
displayName: string;
|
||||||
|
githubLogin: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
verified?: boolean;
|
||||||
|
} | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
versions: Array<{
|
||||||
|
version: string;
|
||||||
|
changelog: string | null;
|
||||||
|
bundleSize: number;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
}>;
|
||||||
|
screenshots: Array<{ url: string; altText: string | null }>;
|
||||||
|
themePreviews: Array<{
|
||||||
|
variant: 'light' | 'dark';
|
||||||
|
previewPath: string;
|
||||||
|
colors: Record<string, string> | null;
|
||||||
|
}>;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
bundle: {
|
||||||
|
manifest: Record<string, unknown> | null;
|
||||||
|
source: { name: string; content: string; truncated: boolean } | null;
|
||||||
|
size: number;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISKY_PERMISSIONS = new Set([
|
||||||
|
'mail:write',
|
||||||
|
'mail:delete',
|
||||||
|
'storage:write',
|
||||||
|
'network',
|
||||||
|
'admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default function MarketplacePreviewPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params.slug as string;
|
||||||
|
|
||||||
|
const [data, setData] = useState<PreviewData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [uninstalling, setUninstalling] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [showSource, setShowSource] = useState(false);
|
||||||
|
const [showManifest, setShowManifest] = useState(false);
|
||||||
|
|
||||||
|
const fetchPreview = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
setError(body.error || 'Failed to load preview');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setData(await res.json());
|
||||||
|
} catch {
|
||||||
|
setError('Failed to connect to extension directory');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPreview(); }, [fetchPreview]);
|
||||||
|
|
||||||
|
async function handleInstall() {
|
||||||
|
if (!data) return;
|
||||||
|
setInstalling(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: data.extension.slug,
|
||||||
|
version: data.extension.latestVersion || '1.0.0',
|
||||||
|
type: data.extension.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
|
||||||
|
setData(prev => prev ? { ...prev, installed: true } : prev);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUninstall() {
|
||||||
|
if (!data) return;
|
||||||
|
if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setUninstalling(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const endpoint = data.extension.type === 'theme'
|
||||||
|
? '/api/admin/themes'
|
||||||
|
: '/api/admin/plugins';
|
||||||
|
const res = await apiFetch(endpoint, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: data.extension.slug }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
|
||||||
|
setData(prev => prev ? { ...prev, installed: false } : prev);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
|
||||||
|
} finally {
|
||||||
|
setUninstalling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/marketplace"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = data.extension;
|
||||||
|
const bundle = data.bundle;
|
||||||
|
const isPlugin = ext.type === 'plugin';
|
||||||
|
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||||
|
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||||
|
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/admin/marketplace"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
{isPlugin ? (
|
||||||
|
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<SwatchBook className="w-7 h-7 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
|
||||||
|
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||||
|
{data.installed && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
|
||||||
|
<Check className="w-3 h-3" /> Installed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
|
isPlugin
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||||
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||||
|
}`}>
|
||||||
|
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
|
||||||
|
</span>
|
||||||
|
{ext.author && (
|
||||||
|
<span>by {ext.author.displayName}</span>
|
||||||
|
)}
|
||||||
|
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
|
||||||
|
{ext.license && <span>{ext.license}</span>}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
{ext.totalDownloads.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{data.installed ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="w-4 h-4" />
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleUninstall}
|
||||||
|
disabled={uninstalling}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
|
Uninstall
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={installing || !!bundle.error}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bundle.error && (
|
||||||
|
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Could not preview bundle</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">About</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
|
||||||
|
{ext.longDescription && ext.longDescription !== ext.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
|
||||||
|
)}
|
||||||
|
{ext.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{ext.tags.map(tag => (
|
||||||
|
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
|
||||||
|
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
|
||||||
|
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
|
||||||
|
{ext.githubRepo && (
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${ext.githubRepo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{ext.githubRepo}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Screenshots */}
|
||||||
|
{ext.screenshots.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
{ext.screenshots.map((s, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={s.url}
|
||||||
|
alt={s.altText || `Screenshot ${i + 1}`}
|
||||||
|
className="w-full rounded-md border border-border bg-muted"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme color preview */}
|
||||||
|
{!isPlugin && ext.themePreviews.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
{ext.themePreviews.map(preview => (
|
||||||
|
<ThemeColorSwatch key={preview.variant} preview={preview} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
{isPlugin && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
|
||||||
|
</div>
|
||||||
|
{manifestPerms.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{manifestPerms.map(perm => {
|
||||||
|
const risky = RISKY_PERMISSIONS.has(perm);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={perm}
|
||||||
|
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
|
||||||
|
risky
|
||||||
|
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
|
||||||
|
: 'bg-muted/50 text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||||
|
<code className="font-mono text-xs">{perm}</code>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{frameOrigins.length > 0 && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-border">
|
||||||
|
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
The plugin will be allowed to embed content from these origins.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{frameOrigins.map(origin => (
|
||||||
|
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
|
||||||
|
{origin}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings schema preview */}
|
||||||
|
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">User settings</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
|
||||||
|
<ul className="mt-3 divide-y divide-border">
|
||||||
|
{Object.entries(settingsSchema).map(([key, field]) => (
|
||||||
|
<li key={key} className="py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-foreground">{key}</code>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
|
||||||
|
{field.description && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source / manifest disclosure */}
|
||||||
|
{bundle.manifest && (
|
||||||
|
<section className="border border-border rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManifest(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
|
||||||
|
</div>
|
||||||
|
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
{showManifest && (
|
||||||
|
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
|
||||||
|
{JSON.stringify(bundle.manifest, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bundle.source && (
|
||||||
|
<section className="border border-border rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSource(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
|
||||||
|
{bundle.source.truncated && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
{showSource && (
|
||||||
|
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
|
||||||
|
{bundle.source.content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version history */}
|
||||||
|
{ext.versions.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Version history</h2>
|
||||||
|
<ul className="mt-3 divide-y divide-border">
|
||||||
|
{ext.versions.slice(0, 5).map(v => (
|
||||||
|
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-foreground">v{v.version}</code>
|
||||||
|
{v.publishedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(v.publishedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v.changelog && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{(v.bundleSize / 1024).toFixed(1)} KB
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
|
||||||
|
const colors = preview.colors || {};
|
||||||
|
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
|
||||||
|
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
|
||||||
|
const accent = colors.primary || colors.accent || '#7c5cff';
|
||||||
|
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
|
||||||
|
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
|
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
|
||||||
|
{preview.variant}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
|
||||||
|
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
|
||||||
|
<span style={{ color: fg }}>Card surface</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
title={`${key}: ${value}`}
|
||||||
|
className="inline-block w-4 h-4 rounded border"
|
||||||
|
style={{ background: value, borderColor: border }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
@@ -262,10 +263,11 @@ function ExtensionCard({
|
|||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isPlugin = extension.type === 'plugin';
|
const isPlugin = extension.type === 'plugin';
|
||||||
|
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
<div className="p-4">
|
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||||
@@ -277,7 +279,9 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
|
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
|
||||||
|
{extension.name}
|
||||||
|
</span>
|
||||||
{extension.featured && (
|
{extension.featured && (
|
||||||
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||||
)}
|
)}
|
||||||
@@ -315,7 +319,7 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer (download count + permissions) */}
|
||||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -328,27 +332,34 @@ function ExtensionCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||||
{extension.installed ? (
|
<Eye className="w-3 h-3" />
|
||||||
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
Preview
|
||||||
<Check className="w-3 h-3" />
|
</span>
|
||||||
Installed
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={onInstall}
|
|
||||||
disabled={installing}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{installing ? (
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Quick install button (sits over the link, stops navigation) */}
|
||||||
|
<div className="px-4 pb-4 -mt-1">
|
||||||
|
{extension.installed ? (
|
||||||
|
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||||
|
disabled={installing}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Quick install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-1
@@ -31,6 +31,7 @@ export default function AdminDashboardPage() {
|
|||||||
const [pluginCount, setPluginCount] = useState(0);
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
const [themeCount, setThemeCount] = useState(0);
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||||
|
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,7 +39,7 @@ export default function AdminDashboardPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchDashboardData() {
|
async function fetchDashboardData() {
|
||||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
|
||||||
apiFetch('/api/admin/auth'),
|
apiFetch('/api/admin/auth'),
|
||||||
apiFetch('/api/admin/audit?limit=10'),
|
apiFetch('/api/admin/audit?limit=10'),
|
||||||
apiFetch('/api/config'),
|
apiFetch('/api/config'),
|
||||||
@@ -46,6 +47,7 @@ export default function AdminDashboardPage() {
|
|||||||
apiFetch('/api/admin/plugins').catch(() => null),
|
apiFetch('/api/admin/plugins').catch(() => null),
|
||||||
apiFetch('/api/admin/themes').catch(() => null),
|
apiFetch('/api/admin/themes').catch(() => null),
|
||||||
apiFetch('/api/admin/policy').catch(() => null),
|
apiFetch('/api/admin/policy').catch(() => null),
|
||||||
|
apiFetch('/api/admin/telemetry').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statusRes.ok) setStatus(await statusRes.json());
|
if (statusRes.ok) setStatus(await statusRes.json());
|
||||||
@@ -73,6 +75,12 @@ export default function AdminDashboardPage() {
|
|||||||
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||||
}
|
}
|
||||||
|
if (telemetryRes?.ok) {
|
||||||
|
const telemetry = await telemetryRes.json();
|
||||||
|
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
|
||||||
|
setAccountCounts(telemetry.accountCounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
if (configData?.jmapServerUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +173,16 @@ export default function AdminDashboardPage() {
|
|||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Accounts */}
|
||||||
|
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||||
|
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Extensions */}
|
{/* Extensions */}
|
||||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||||
<SettingItem label="Plugins">
|
<SettingItem label="Plugins">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||||
|
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface TelemetryStatus {
|
||||||
|
consent: 'pending' | 'on' | 'off';
|
||||||
|
consentSource: 'env' | 'file';
|
||||||
|
endpoint: string;
|
||||||
|
defaultEndpoint: string;
|
||||||
|
consentedAt: string | null;
|
||||||
|
lastSentAt: string | null;
|
||||||
|
nextScheduledAt: string | null;
|
||||||
|
payloadPreview: Record<string, unknown>;
|
||||||
|
accountCounts: { total: number; active7d: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
|
if (d < 0) return new Date(iso).toLocaleString();
|
||||||
|
const m = Math.floor(d / 60000);
|
||||||
|
if (m < 1) return 'just now';
|
||||||
|
if (m < 60) return `${m} min ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 48) return `${h} hours ago`;
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
return `${days} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminTelemetryPage() {
|
||||||
|
const [status, setStatus] = useState<TelemetryStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [endpointDraft, setEndpointDraft] = useState('');
|
||||||
|
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry');
|
||||||
|
if (!r.ok) throw new Error('failed to load');
|
||||||
|
const data = (await r.json()) as TelemetryStatus;
|
||||||
|
setStatus(data);
|
||||||
|
setEndpointDraft(data.endpoint);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void refresh(); }, []);
|
||||||
|
|
||||||
|
async function setConsent(consent: 'on' | 'off'): Promise<void> {
|
||||||
|
setBusy('consent');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set-consent', consent }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||||
|
alert(j.error ?? 'failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEndpoint(): Promise<void> {
|
||||||
|
setBusy('endpoint');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||||
|
alert(j.error ?? 'failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNow(): Promise<void> {
|
||||||
|
setBusy('send');
|
||||||
|
setSendResult(null);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'send-now' }),
|
||||||
|
});
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
|
||||||
|
setSendResult({
|
||||||
|
ok: !!j.ok,
|
||||||
|
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !status) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOverridden = status.consentSource === 'env';
|
||||||
|
const isOn = status.consent === 'on';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
||||||
|
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
||||||
|
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
||||||
|
<a
|
||||||
|
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Full schema and policy <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Status</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||||
|
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
||||||
|
{status.consent === 'off' && 'Heartbeats are off.'}
|
||||||
|
{envOverridden && (
|
||||||
|
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'consent' || envOverridden || isOn}
|
||||||
|
onClick={() => void setConsent('on')}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
|
||||||
|
onClick={() => void setConsent('off')}
|
||||||
|
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
|
||||||
|
<dt className="text-muted-foreground">Last sent</dt>
|
||||||
|
<dd>{timeAgo(status.lastSentAt)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Next scheduled</dt>
|
||||||
|
<dd>{timeAgo(status.nextScheduledAt)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Consented at</dt>
|
||||||
|
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-2">
|
||||||
|
<div className="font-medium">Account activity</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Unique accounts that have logged in over the last 90 days. Identities are stored as a
|
||||||
|
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
|
||||||
|
heartbeat as bucketed ranges.
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
||||||
|
<dt className="text-muted-foreground">Total (90d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
||||||
|
<dt className="text-muted-foreground">Active (7d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="font-medium">Endpoint</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
|
||||||
|
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={endpointDraft}
|
||||||
|
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||||
|
placeholder={status.defaultEndpoint}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||||
|
onClick={() => void saveEndpoint()}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Payload preview</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Exactly what the next heartbeat would send from this install, right now.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'send' || !isOn}
|
||||||
|
onClick={() => void sendNow()}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" /> Send now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sendResult && (
|
||||||
|
<div
|
||||||
|
className={`text-sm flex items-center gap-2 ${
|
||||||
|
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||||
|
{sendResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||||
|
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,11 +53,19 @@ export async function GET(
|
|||||||
|
|
||||||
const buffer = await readFile(resolved);
|
const buffer = await readFile(resolved);
|
||||||
|
|
||||||
|
// SVG can carry inline <script> and event handlers that execute when the
|
||||||
|
// file is fetched as a top-level document. Defense in depth on top of
|
||||||
|
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
|
||||||
|
// sandboxed unique origin so any script in an SVG is inert and cannot
|
||||||
|
// touch app cookies or storage.
|
||||||
return new NextResponse(buffer, {
|
return new NextResponse(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
'Content-Length': String(buffer.length),
|
'Content-Length': String(buffer.length),
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Content-Security-Policy':
|
||||||
|
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
getPluginRegistry,
|
||||||
|
getThemeRegistry,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
|
||||||
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||||
|
|
||||||
|
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/marketplace/[slug]
|
||||||
|
* Returns full preview info for an extension: directory metadata,
|
||||||
|
* the bundle's manifest, a (truncated) source preview, and install status.
|
||||||
|
* Lets admins audit what they're about to install before pressing the button.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||||
|
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
||||||
|
const detailRes = await fetch(detailUrl.toString(), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!detailRes.ok) {
|
||||||
|
const status = detailRes.status === 404 ? 404 : 502;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailJson = await detailRes.json();
|
||||||
|
const extension = detailJson.data as Record<string, unknown> | undefined;
|
||||||
|
if (!extension) {
|
||||||
|
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = extension.type as 'plugin' | 'theme';
|
||||||
|
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
// 2. Pull the bundle so we can show what's actually inside.
|
||||||
|
let manifest: Record<string, unknown> | null = null;
|
||||||
|
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
|
||||||
|
let bundleError: string | null = null;
|
||||||
|
let bundleSize = 0;
|
||||||
|
|
||||||
|
if (latestVersion) {
|
||||||
|
try {
|
||||||
|
const bundleUrl = new URL(
|
||||||
|
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||||
|
DIRECTORY_URL,
|
||||||
|
);
|
||||||
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bundleRes.ok) {
|
||||||
|
bundleError = `Bundle download failed (${bundleRes.status})`;
|
||||||
|
} else {
|
||||||
|
const buffer = await bundleRes.arrayBuffer();
|
||||||
|
bundleSize = buffer.byteLength;
|
||||||
|
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||||
|
if (buffer.byteLength > maxSize) {
|
||||||
|
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
|
||||||
|
} else {
|
||||||
|
const zip = await JSZip.loadAsync(buffer);
|
||||||
|
|
||||||
|
// Detect optional root directory inside the ZIP.
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
bundleError = 'Bundle missing manifest.json';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
bundleError = 'Invalid manifest.json in bundle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest) {
|
||||||
|
if (type === 'theme') {
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (cssFile) {
|
||||||
|
const css = await cssFile.async('string');
|
||||||
|
sourcePreview = {
|
||||||
|
name: 'theme.css',
|
||||||
|
content: css.length > MAX_PREVIEW_SOURCE_LEN
|
||||||
|
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||||
|
: css,
|
||||||
|
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||||
|
const jsFile = zip.file(root + entrypoint);
|
||||||
|
if (jsFile) {
|
||||||
|
const code = await jsFile.async('string');
|
||||||
|
sourcePreview = {
|
||||||
|
name: entrypoint,
|
||||||
|
content: code.length > MAX_PREVIEW_SOURCE_LEN
|
||||||
|
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||||
|
: code,
|
||||||
|
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bundleError = 'Extension has no published version';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Install status (slug is used as the registry id at install time)
|
||||||
|
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||||
|
getPluginRegistry(),
|
||||||
|
getThemeRegistry(),
|
||||||
|
]);
|
||||||
|
const installed = type === 'theme'
|
||||||
|
? themeRegistry.themes.some((t) => t.id === slug)
|
||||||
|
: pluginRegistry.plugins.some((p) => p.id === slug);
|
||||||
|
|
||||||
|
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||||
|
const screenshots = Array.isArray(extension.screenshots)
|
||||||
|
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||||
|
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
||||||
|
altText: s.altText ?? null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Strip the heavy `manifest` blob from versions when echoing the directory data.
|
||||||
|
const versions = Array.isArray(extension.versions)
|
||||||
|
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
|
||||||
|
version: v.version,
|
||||||
|
changelog: v.changelog,
|
||||||
|
bundleSize: v.bundleSize,
|
||||||
|
minAppVersion: v.minAppVersion,
|
||||||
|
publishedAt: v.publishedAt,
|
||||||
|
permissions: v.permissions,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
extension: {
|
||||||
|
slug: extension.slug,
|
||||||
|
name: extension.name,
|
||||||
|
type: extension.type,
|
||||||
|
pluginType: extension.pluginType ?? null,
|
||||||
|
description: extension.description,
|
||||||
|
longDescription: extension.longDescription ?? null,
|
||||||
|
tags: extension.tags ?? [],
|
||||||
|
permissions: extension.permissions ?? [],
|
||||||
|
totalDownloads: extension.totalDownloads ?? 0,
|
||||||
|
featured: extension.featured ?? false,
|
||||||
|
githubRepo: extension.githubRepo ?? null,
|
||||||
|
license: extension.license ?? null,
|
||||||
|
minAppVersion: extension.minAppVersion ?? null,
|
||||||
|
author: extension.author ?? null,
|
||||||
|
latestVersion,
|
||||||
|
versions,
|
||||||
|
screenshots,
|
||||||
|
themePreviews: extension.themePreviews ?? [],
|
||||||
|
createdAt: extension.createdAt ?? null,
|
||||||
|
updatedAt: extension.updatedAt ?? null,
|
||||||
|
},
|
||||||
|
bundle: {
|
||||||
|
manifest,
|
||||||
|
source: sourcePreview,
|
||||||
|
size: bundleSize,
|
||||||
|
error: bundleError,
|
||||||
|
},
|
||||||
|
installed,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace preview error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to load preview' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import JSZip from 'jszip';
|
|||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Plugins may declare iframe origins they need for embedded content.
|
// Plugins may declare iframe origins they need for embedded content.
|
||||||
// Anything that doesn't pass strict origin validation is silently
|
// Anything that doesn't pass strict origin validation is silently
|
||||||
// dropped — the plugin still installs, but those origins are not
|
// dropped - the plugin still installs, but those origins are not
|
||||||
// added to the host CSP.
|
// added to the host CSP.
|
||||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const queryEntry = queryRes.methodResponses?.[0];
|
const queryEntry = queryRes.methodResponses?.[0];
|
||||||
if (!queryEntry || queryEntry[0] === 'error') {
|
if (!queryEntry || queryEntry[0] === 'error') {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: 'Stalwart denied OAuthClient/query — your Stalwart account likely lacks admin permissions.',
|
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
|
||||||
detail: queryEntry?.[1],
|
detail: queryEntry?.[1],
|
||||||
}, { status: 403 });
|
}, { status: 403 });
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const setEntry = setRes.methodResponses?.[0];
|
const setEntry = setRes.methodResponses?.[0];
|
||||||
if (!setEntry || setEntry[0] === 'error') {
|
if (!setEntry || setEntry[0] === 'error') {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: 'Stalwart denied OAuthClient/set — admin permissions required.',
|
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
|
||||||
detail: setEntry?.[1],
|
detail: setEntry?.[1],
|
||||||
}, { status: 403 });
|
}, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
import { requireAdminAuth } from '@/lib/admin/session';
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
|
* GET /api/admin/plugins/[id]/config - Read plugin config
|
||||||
*
|
*
|
||||||
* Returns the full config object for admin-configured plugin settings.
|
* - Admin sessions receive every field, including those declared
|
||||||
* This endpoint is accessible from the client-side plugin API.
|
* `type: 'secret'` in the plugin's configSchema.
|
||||||
|
* - Authenticated mailbox users (the plugin running in their browser)
|
||||||
|
* receive only non-secret fields.
|
||||||
|
* - Anonymous callers are rejected so unauthenticated visitors cannot
|
||||||
|
* enumerate plugin secrets.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -20,13 +25,34 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminAuth = await requireAdminAuth();
|
||||||
|
const isAdmin = !('error' in adminAuth);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await getPlugin(id);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getPluginConfig(id);
|
const config = await getPluginConfig(id);
|
||||||
return NextResponse.json(config, {
|
|
||||||
|
let response: Record<string, unknown> = config;
|
||||||
|
if (!isAdmin && plugin.configSchema) {
|
||||||
|
response = {};
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
const field = plugin.configSchema[key];
|
||||||
|
if (field?.type === 'secret') continue;
|
||||||
|
response[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
effectiveConsent,
|
||||||
|
loadState,
|
||||||
|
saveState,
|
||||||
|
buildPayload,
|
||||||
|
sendOnce,
|
||||||
|
reschedule,
|
||||||
|
DEFAULT_ENDPOINT,
|
||||||
|
getLoginCounts,
|
||||||
|
resolveEndpointAllowed,
|
||||||
|
} from '@/lib/telemetry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/telemetry
|
||||||
|
* Returns current consent + endpoint + next/last send + a live preview
|
||||||
|
* of exactly what the next heartbeat would contain.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
|
const { consent, source, state } = await effectiveConsent();
|
||||||
|
const [payload, accountCounts] = await Promise.all([
|
||||||
|
buildPayload(),
|
||||||
|
getLoginCounts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
consent,
|
||||||
|
consentSource: source,
|
||||||
|
endpoint: state.endpoint || DEFAULT_ENDPOINT,
|
||||||
|
consentedAt: state.consentedAt,
|
||||||
|
lastSentAt: state.lastSentAt,
|
||||||
|
nextScheduledAt: state.nextScheduledAt,
|
||||||
|
defaultEndpoint: DEFAULT_ENDPOINT,
|
||||||
|
payloadPreview: payload,
|
||||||
|
accountCounts,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('telemetry GET error', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/telemetry
|
||||||
|
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
|
||||||
|
* set-consent : { action, consent: 'on' | 'off' }
|
||||||
|
* set-endpoint : { action, endpoint: string }
|
||||||
|
* send-now : { action }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as
|
||||||
|
| { action?: string; consent?: string; endpoint?: string }
|
||||||
|
| null;
|
||||||
|
if (!body || typeof body.action !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'action required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { source } = await effectiveConsent();
|
||||||
|
|
||||||
|
if (body.action === 'set-consent') {
|
||||||
|
if (source === 'env') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.consent !== 'on' && body.consent !== 'off') {
|
||||||
|
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const state = await loadState();
|
||||||
|
const before = state.consent;
|
||||||
|
state.consent = body.consent;
|
||||||
|
if (body.consent === 'on' && !state.consentedAt) {
|
||||||
|
state.consentedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
await saveState(state);
|
||||||
|
await reschedule();
|
||||||
|
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'set-endpoint') {
|
||||||
|
if (typeof body.endpoint !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const trimmed = body.endpoint.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
const check = await resolveEndpointAllowed(trimmed);
|
||||||
|
if (!check.ok) {
|
||||||
|
return NextResponse.json({ error: check.reason }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const state = await loadState();
|
||||||
|
const before = state.endpoint;
|
||||||
|
state.endpoint = trimmed || DEFAULT_ENDPOINT;
|
||||||
|
await saveState(state);
|
||||||
|
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
|
||||||
|
return NextResponse.json({ ok: true, endpoint: state.endpoint });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'send-now') {
|
||||||
|
const result = await sendOnce({ reason: 'admin-manual' });
|
||||||
|
await auditLog(
|
||||||
|
'telemetry.send-now',
|
||||||
|
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('telemetry POST error', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
setStalwartAuthContextInStore,
|
setStalwartAuthContextInStore,
|
||||||
} from '@/lib/stalwart/auth-context';
|
} from '@/lib/stalwart/auth-context';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -50,6 +51,8 @@ export async function POST(request: NextRequest) {
|
|||||||
authHeader,
|
authHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JmapAuthVerificationError) {
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
@@ -32,6 +33,8 @@ export async function POST(request: NextRequest) {
|
|||||||
authHeader,
|
authHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JmapAuthVerificationError) {
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
|||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -84,22 +86,39 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
// public URL the browser uses, e.g. inside Docker).
|
// caller cannot point this route at internal hosts. Only when no server
|
||||||
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
// URL is configured (and the deployment explicitly allows custom JMAP
|
||||||
|
// endpoints) do we fall back to the user-supplied URL - and even then
|
||||||
|
// it must resolve to a public address.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const configuredServerUrl =
|
||||||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
|
process.env.JMAP_SERVER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
|
'';
|
||||||
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
|
||||||
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
|
let upstreamUrl: string;
|
||||||
if (!tokenEndpoint) {
|
if (configuredServerUrl) {
|
||||||
// Also try with the client-provided URL in case the internal one differs
|
upstreamUrl = configuredServerUrl;
|
||||||
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
} else if (allowCustomEndpoint) {
|
||||||
if (!clientEndpoint) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||||
}
|
}
|
||||||
return await attemptAllStrategies(clientEndpoint, username, password, slot);
|
upstreamUrl = serverUrl;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
|
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
logger.warn('TOTP token exchange: no token endpoint found');
|
||||||
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
@@ -108,6 +127,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
async function attemptAllStrategies(
|
async function attemptAllStrategies(
|
||||||
tokenEndpoint: string,
|
tokenEndpoint: string,
|
||||||
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
slot: number,
|
slot: number,
|
||||||
@@ -126,6 +146,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
@@ -137,6 +158,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
@@ -148,6 +170,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
@@ -159,6 +182,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface CalendarDayViewProps {
|
|||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -39,6 +40,7 @@ export function CalendarDayView({
|
|||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
onContextMenuEvent,
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -144,7 +146,13 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div
|
||||||
|
className="px-4 py-2 border-b border-border"
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
|
||||||
|
onContextMenuEmpty(e, selectedDate, undefined, true);
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
{allDayEvents.length > 0 && (
|
{allDayEvents.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||||
@@ -240,6 +248,7 @@ export function CalendarDayView({
|
|||||||
aria-label={formatHour(h)}
|
aria-label={formatHour(h)}
|
||||||
onClick={() => handleSlotClick(selectedDate, h)}
|
onClick={() => handleSlotClick(selectedDate, h)}
|
||||||
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface CalendarMonthViewProps {
|
|||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -39,6 +40,7 @@ export function CalendarMonthView({
|
|||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
onContextMenuEvent,
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -174,6 +176,7 @@ export function CalendarMonthView({
|
|||||||
aria-label={fullDateLabel}
|
aria-label={fullDateLabel}
|
||||||
onClick={() => onSelectDate(day)}
|
onClick={() => onSelectDate(day)}
|
||||||
onDoubleClick={() => onCreateAtTime?.(day)}
|
onDoubleClick={() => onCreateAtTime?.(day)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||||
onDragOver={(e) => handleCellDragOver(e, key)}
|
onDragOver={(e) => handleCellDragOver(e, key)}
|
||||||
onDragLeave={handleCellDragLeave}
|
onDragLeave={handleCellDragLeave}
|
||||||
onDrop={(e) => handleCellDrop(e, day)}
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface CalendarWeekViewProps {
|
|||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
@@ -44,6 +45,7 @@ export function CalendarWeekView({
|
|||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
onContextMenuEvent,
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
@@ -219,7 +221,11 @@ export function CalendarWeekView({
|
|||||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
<div
|
||||||
|
key={format(day, "yyyy-MM-dd")}
|
||||||
|
className="bg-background min-h-[28px]"
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
@@ -378,6 +384,7 @@ export function CalendarWeekView({
|
|||||||
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
||||||
onClick={() => handleSlotClick(day, h)}
|
onClick={() => handleSlotClick(day, h)}
|
||||||
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { Plus, CalendarDays, CheckSquare, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmptySpaceContextMenuProps {
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onNewEvent: () => void;
|
||||||
|
onNewAllDayEvent: () => void;
|
||||||
|
onNewTask?: () => void;
|
||||||
|
onGoToToday: () => void;
|
||||||
|
showAllDayOption?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptySpaceContextMenu({
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onNewEvent,
|
||||||
|
onNewAllDayEvent,
|
||||||
|
onNewTask,
|
||||||
|
onGoToToday,
|
||||||
|
showAllDayOption = true,
|
||||||
|
}: EmptySpaceContextMenuProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Plus} label={t("events.new_event")} onClick={handle(onNewEvent)} />
|
||||||
|
{showAllDayOption && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CalendarDays}
|
||||||
|
label={t("events.new_all_day_event")}
|
||||||
|
onClick={handle(onNewAllDayEvent)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onNewTask && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CheckSquare}
|
||||||
|
label={t("events.new_task")}
|
||||||
|
onClick={handle(onNewTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} onClick={handle(onGoToToday)} />
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ interface EventModalProps {
|
|||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultEndDate?: Date;
|
defaultEndDate?: Date;
|
||||||
|
defaultAllDay?: boolean;
|
||||||
defaultCalendarId?: string;
|
defaultCalendarId?: string;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
@@ -114,6 +115,7 @@ export function EventModal({
|
|||||||
calendars,
|
calendars,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
defaultEndDate,
|
defaultEndDate,
|
||||||
|
defaultAllDay,
|
||||||
defaultCalendarId,
|
defaultCalendarId,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -199,7 +201,7 @@ export function EventModal({
|
|||||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
|
||||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||||
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ export function NavigationRail({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */}
|
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ services:
|
|||||||
- bulwark-settings:/app/data/settings
|
- bulwark-settings:/app/data/settings
|
||||||
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
|
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
|
||||||
- bulwark-admin:/app/data/admin
|
- bulwark-admin:/app/data/admin
|
||||||
|
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
|
||||||
|
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
|
||||||
|
- bulwark-telemetry:/app/data/telemetry
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -32,3 +35,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
bulwark-settings:
|
bulwark-settings:
|
||||||
bulwark-admin:
|
bulwark-admin:
|
||||||
|
bulwark-telemetry:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface UseKeyboardShortcutsOptions {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
emails: Email[];
|
emails: Email[];
|
||||||
selectedEmailId?: string;
|
selectedEmailId?: string;
|
||||||
|
selectionCount?: number;
|
||||||
handlers: KeyboardShortcutHandlers;
|
handlers: KeyboardShortcutHandlers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts({
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
emails,
|
emails,
|
||||||
selectedEmailId,
|
selectedEmailId,
|
||||||
|
selectionCount = 0,
|
||||||
handlers,
|
handlers,
|
||||||
}: UseKeyboardShortcutsOptions) {
|
}: UseKeyboardShortcutsOptions) {
|
||||||
const handlersRef = useRef(handlers);
|
const handlersRef = useRef(handlers);
|
||||||
@@ -90,6 +92,8 @@ export function useKeyboardShortcuts({
|
|||||||
// Shortcuts that should NOT work with modifiers
|
// Shortcuts that should NOT work with modifiers
|
||||||
if (hasModifier) return;
|
if (hasModifier) return;
|
||||||
|
|
||||||
|
const hasBatchTarget = !!selectedEmailId || selectionCount > 0;
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
// Navigation
|
// Navigation
|
||||||
case "j":
|
case "j":
|
||||||
@@ -152,7 +156,7 @@ export function useKeyboardShortcuts({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "e":
|
case "e":
|
||||||
if (selectedEmailId) {
|
if (hasBatchTarget) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onArchive?.();
|
h.onArchive?.();
|
||||||
}
|
}
|
||||||
@@ -161,28 +165,28 @@ export function useKeyboardShortcuts({
|
|||||||
case "#":
|
case "#":
|
||||||
case "delete":
|
case "delete":
|
||||||
case "backspace":
|
case "backspace":
|
||||||
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) {
|
if (hasBatchTarget) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onDelete?.();
|
h.onDelete?.();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "u":
|
case "u":
|
||||||
if (selectedEmailId) {
|
if (hasBatchTarget) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onMarkAsUnread?.();
|
h.onMarkAsUnread?.();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "i":
|
case "i":
|
||||||
if (selectedEmailId && event.shiftKey) {
|
if (hasBatchTarget && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onMarkAsRead?.();
|
h.onMarkAsRead?.();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "!":
|
case "!":
|
||||||
if (selectedEmailId) {
|
if (hasBatchTarget) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onToggleSpam?.();
|
h.onToggleSpam?.();
|
||||||
}
|
}
|
||||||
@@ -220,7 +224,7 @@ export function useKeyboardShortcuts({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedEmailId]
|
[selectedEmailId, selectionCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ configManager.load()
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
console.info("Admin dashboard initialized");
|
console.info("Admin dashboard initialized");
|
||||||
})
|
})
|
||||||
|
.then(async () => {
|
||||||
|
// Anonymous telemetry - on by default. Admins can disable via the
|
||||||
|
// admin UI, the BULWARK_TELEMETRY env var, or by clearing the endpoint.
|
||||||
|
// See https://bulwarkmail.org/docs/legal/privacy/telemetry
|
||||||
|
const { startScheduler, markProcessStart } = await import("./lib/telemetry");
|
||||||
|
markProcessStart();
|
||||||
|
await startScheduler();
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,23 +28,11 @@ describe('extractTheme', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects oversized theme', async () => {
|
it('rejects oversized theme', async () => {
|
||||||
const zip = new JSZip();
|
// Theme size limit is 2 MB; create a file just past it.
|
||||||
zip.file('manifest.json', JSON.stringify({
|
const oversizedFile = new File([new ArrayBuffer(2 * 1024 * 1024 + 1)], 'big.zip');
|
||||||
id: 'big-theme',
|
|
||||||
name: 'Big',
|
|
||||||
version: '1.0.0',
|
|
||||||
author: 'Test',
|
|
||||||
type: 'theme',
|
|
||||||
variants: ['light'],
|
|
||||||
}));
|
|
||||||
// Make a large file > 1MB
|
|
||||||
zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1));
|
|
||||||
|
|
||||||
// Manually create oversized File
|
|
||||||
const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip');
|
|
||||||
const result = await extractTheme(oversizedFile);
|
const result = await extractTheme(oversizedFile);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit');
|
expect(result.errors).toContain('Theme ZIP exceeds 2 MB size limit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-ZIP file', async () => {
|
it('rejects non-ZIP file', async () => {
|
||||||
@@ -139,6 +127,129 @@ describe('extractTheme', () => {
|
|||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.manifest!.id).toBe('nested-theme');
|
expect(result.manifest!.id).toBe('nested-theme');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Theme API v2 (advanced manifest) ──────────────────────────────
|
||||||
|
|
||||||
|
it('compiles a v2 manifest with tokens and no theme.css', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'tokens-only',
|
||||||
|
name: 'Tokens Only',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light', 'dark'],
|
||||||
|
apiVersion: 2,
|
||||||
|
tokens: {
|
||||||
|
light: { primary: '#1373d9', background: '#ffffff' },
|
||||||
|
dark: { primary: '#58c9ff', background: '#1a202c' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.css).toContain('--color-primary: #1373d9');
|
||||||
|
expect(result.css).toContain('--color-primary: #58c9ff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('concatenates compiled tokens with author-supplied theme.css', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'tokens-plus-css',
|
||||||
|
name: 'Tokens + CSS',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
apiVersion: 2,
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
}));
|
||||||
|
zip.file('theme.css', '@font-face { font-family: "X"; src: local("X"); }');
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.css).toContain('--color-primary: #000');
|
||||||
|
expect(result.css).toContain('@font-face');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a skin.css when shipped with a v2 manifest', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'with-skin',
|
||||||
|
name: 'With Skin',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
apiVersion: 2,
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
}));
|
||||||
|
zip.file('skin.css', '[data-tour="email-list"] { font-size: 13px; }');
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.skin).not.toBeNull();
|
||||||
|
expect(result.skin!).toContain('[data-tour="email-list"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dangerous patterns from skin.css', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'evil-skin',
|
||||||
|
name: 'Evil',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
apiVersion: 2,
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
}));
|
||||||
|
zip.file('skin.css', '@import url("https://x.com/p.css"); button { background: javascript:alert(1); }');
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.skin).not.toBeNull();
|
||||||
|
expect(result.skin!).not.toContain('javascript:');
|
||||||
|
expect(result.skin!).not.toContain('@import');
|
||||||
|
expect(result.warnings.some((w) => w.toLowerCase().includes('skin'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores skin.css when manifest is not v2', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'v1-with-skin',
|
||||||
|
name: 'V1',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
}));
|
||||||
|
zip.file('theme.css', ':root { --color-primary: #000; }');
|
||||||
|
zip.file('skin.css', 'body { display: none; }');
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.skin).toBeNull();
|
||||||
|
expect(result.warnings.some((w) => w.includes('skin.css ignored'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a v2 manifest with invalid density', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'bad-density',
|
||||||
|
name: 'Bad',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
density: 'gigantic',
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
}));
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.includes('density'))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractPlugin', () => {
|
describe('extractPlugin', () => {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { compileAdvancedTheme, isAdvancedManifest } from '../theme-compiler';
|
||||||
|
import type { ThemeManifest } from '../plugin-types';
|
||||||
|
|
||||||
|
const baseManifest = (overrides: Partial<ThemeManifest> = {}): ThemeManifest => ({
|
||||||
|
id: 't',
|
||||||
|
name: 'T',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'tester',
|
||||||
|
description: '',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light', 'dark'],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAdvancedManifest', () => {
|
||||||
|
it('returns false for plain v1 manifests', () => {
|
||||||
|
expect(isAdvancedManifest(baseManifest())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ apiVersion: 2 as const },
|
||||||
|
{ tokens: { light: { primary: '#000' } } },
|
||||||
|
{ extends: 'builtin-nord' },
|
||||||
|
{ derive: true },
|
||||||
|
{ density: 'compact' as const },
|
||||||
|
{ radii: { md: '6px' } },
|
||||||
|
{ typography: { fontSans: 'Inter' } },
|
||||||
|
])('returns true when manifest has %p', (extra) => {
|
||||||
|
expect(isAdvancedManifest(baseManifest(extra))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compileAdvancedTheme', () => {
|
||||||
|
it('emits :root and .dark blocks from token sets', () => {
|
||||||
|
const { css, errors } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: {
|
||||||
|
light: { primary: '#1373d9', background: '#ffffff' },
|
||||||
|
dark: { primary: '#58c9ff', background: '#1a202c' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary:\s*#1373d9/);
|
||||||
|
expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary:\s*#58c9ff/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits .dark block for light-only themes', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
variants: ['light'],
|
||||||
|
tokens: { light: { primary: '#000' }, dark: { primary: '#fff' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain(':root');
|
||||||
|
expect(css).not.toContain('.dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits common tokens into both :root and .dark', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: {
|
||||||
|
common: { ring: '#abc' },
|
||||||
|
light: { background: '#fff' },
|
||||||
|
dark: { background: '#000' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rootMatch = css.match(/:root\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
||||||
|
const darkMatch = css.match(/\.dark\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
||||||
|
expect(rootMatch).toContain('--color-ring: #abc');
|
||||||
|
expect(darkMatch).toContain('--color-ring: #abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives a contrasting *-foreground when derive: true', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
derive: true,
|
||||||
|
tokens: { light: { primary: '#000000' }, dark: { primary: '#ffffff' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary-foreground:\s*#ffffff/);
|
||||||
|
expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary-foreground:\s*#0f172a/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects an author-provided *-foreground over derive', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
derive: true,
|
||||||
|
tokens: {
|
||||||
|
light: { primary: '#000000', 'primary-foreground': '#ff00ff' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain('--color-primary-foreground: #ff00ff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits radii, typography, and density vars', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
radii: { sm: '2px', md: '6px', full: '9999px' },
|
||||||
|
typography: { fontSans: 'Inter, sans-serif', baseFontSize: '15px' },
|
||||||
|
density: 'compact',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain('--radius-sm: 2px');
|
||||||
|
expect(css).toContain('--radius-full: 9999px');
|
||||||
|
expect(css).toContain('--font-sans: Inter, sans-serif');
|
||||||
|
expect(css).toContain('--font-size-base: 15px');
|
||||||
|
expect(css).toContain('--density-row-height: 28px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops tokens with unsafe values and warns', () => {
|
||||||
|
const { css, warnings } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: {
|
||||||
|
light: {
|
||||||
|
primary: '#000',
|
||||||
|
evil: 'red; background: url("https://x.com/track.png")',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain('--color-primary: #000');
|
||||||
|
expect(css).not.toContain('https://x.com');
|
||||||
|
expect(warnings.some((w) => w.includes('evil'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops tokens with unsafe keys and warns', () => {
|
||||||
|
const { css, warnings } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: { light: { 'primary }; body { background: red': '#fff', primary: '#000' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain('--color-primary: #000');
|
||||||
|
expect(css).not.toContain('body { background');
|
||||||
|
expect(warnings.some((w) => w.includes('invalid key'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when no structured fields are present', () => {
|
||||||
|
const { errors } = compileAdvancedTheme(baseManifest());
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inlines parent CSS when extends + resolver supplied', () => {
|
||||||
|
const { css, warnings } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
extends: 'parent-theme',
|
||||||
|
tokens: { light: { primary: '#fff' } },
|
||||||
|
}),
|
||||||
|
{ resolveExtends: (id) => (id === 'parent-theme' ? ':root { --x: 1; }' : null) },
|
||||||
|
);
|
||||||
|
expect(css).toContain('--x: 1');
|
||||||
|
expect(css).toContain('--color-primary: #fff');
|
||||||
|
expect(warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when extends parent cannot be resolved', () => {
|
||||||
|
const { warnings } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
extends: 'missing',
|
||||||
|
tokens: { light: { primary: '#fff' } },
|
||||||
|
}),
|
||||||
|
{ resolveExtends: () => null },
|
||||||
|
);
|
||||||
|
expect(warnings.some((w) => w.includes('missing'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends user-supplied CSS after compiled output', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({ tokens: { light: { primary: '#fff' } } }),
|
||||||
|
{ userCSS: '@font-face { font-family: "X"; src: local("X"); }' },
|
||||||
|
);
|
||||||
|
const compiledIdx = css.indexOf('--color-primary');
|
||||||
|
const userIdx = css.indexOf('@font-face');
|
||||||
|
expect(compiledIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(userIdx).toBeGreaterThan(compiledIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import {
|
import {
|
||||||
sanitizeThemeCSS,
|
sanitizeThemeCSS,
|
||||||
|
sanitizeSkinCSS,
|
||||||
validateThemeSelectors,
|
validateThemeSelectors,
|
||||||
injectThemeCSS,
|
injectThemeCSS,
|
||||||
removeThemeCSS,
|
removeThemeCSS,
|
||||||
|
injectThemeSkinCSS,
|
||||||
|
removeThemeSkinCSS,
|
||||||
validateThemeCSSSafety,
|
validateThemeCSSSafety,
|
||||||
} from '../theme-loader';
|
} from '../theme-loader';
|
||||||
|
|
||||||
@@ -144,6 +147,64 @@ describe('theme-loader', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('injectThemeSkinCSS / removeThemeSkinCSS', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
removeThemeSkinCSS();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects a separate <style> tag from the colour block', () => {
|
||||||
|
injectThemeCSS(':root { --color-primary: red; }');
|
||||||
|
injectThemeSkinCSS('button { padding: 4px; }', 'thunderbird');
|
||||||
|
expect(document.getElementById('active-theme')).not.toBeNull();
|
||||||
|
expect(document.getElementById('active-theme-skin')).not.toBeNull();
|
||||||
|
expect(document.getElementById('active-theme-skin')?.textContent).toContain('button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets data-theme-skin on body to the active theme id', () => {
|
||||||
|
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
|
||||||
|
expect(document.body.getAttribute('data-theme-skin')).toBe('my-theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the skin tag and body attribute on remove', () => {
|
||||||
|
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
|
||||||
|
removeThemeSkinCSS();
|
||||||
|
expect(document.getElementById('active-theme-skin')).toBeNull();
|
||||||
|
expect(document.body.getAttribute('data-theme-skin')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when removing without a prior inject', () => {
|
||||||
|
expect(() => removeThemeSkinCSS()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeSkinCSS', () => {
|
||||||
|
it('preserves component-level selectors', () => {
|
||||||
|
const css = '[data-tour="email-list"] { font-size: 13px; } button { padding: 4px; }';
|
||||||
|
const { css: cleaned, warnings } = sanitizeSkinCSS(css);
|
||||||
|
expect(cleaned).toBe(css);
|
||||||
|
expect(warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dangerous patterns', () => {
|
||||||
|
const { css: cleaned, warnings } = sanitizeSkinCSS(
|
||||||
|
'@import url("https://x.com/p.css"); button { background: javascript:alert(1); }',
|
||||||
|
);
|
||||||
|
expect(cleaned).not.toContain('@import');
|
||||||
|
expect(cleaned).not.toContain('javascript:');
|
||||||
|
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips @charset and @namespace', () => {
|
||||||
|
const { css: cleaned, warnings } = sanitizeSkinCSS(
|
||||||
|
'@charset "utf-8"; @namespace url(http://www.w3.org/1999/xhtml); button { padding: 4px; }',
|
||||||
|
);
|
||||||
|
expect(cleaned).not.toContain('@charset');
|
||||||
|
expect(cleaned).not.toContain('@namespace');
|
||||||
|
expect(cleaned).toContain('button');
|
||||||
|
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('validateThemeCSSSafety', () => {
|
describe('validateThemeCSSSafety', () => {
|
||||||
it('accepts valid theme CSS', () => {
|
it('accepts valid theme CSS', () => {
|
||||||
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
|
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||||
|
|
||||||
function getAdminDir(): string {
|
function getAdminDir(): string {
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
||||||
@@ -35,6 +35,7 @@ class ConfigManager {
|
|||||||
this.policyCache = {
|
this.policyCache = {
|
||||||
...DEFAULT_POLICY,
|
...DEFAULT_POLICY,
|
||||||
...policy,
|
...policy,
|
||||||
|
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -143,7 +144,12 @@ class ConfigManager {
|
|||||||
* Update the settings policy. Writes to disk.
|
* Update the settings policy. Writes to disk.
|
||||||
*/
|
*/
|
||||||
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
||||||
this.policyCache = { ...DEFAULT_POLICY, ...policy };
|
this.policyCache = {
|
||||||
|
...DEFAULT_POLICY,
|
||||||
|
...policy,
|
||||||
|
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||||
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||||
|
};
|
||||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* domains in the host CSP.
|
* domains in the host CSP.
|
||||||
*
|
*
|
||||||
* Origins are validated at install time and re-validated here as defense in
|
* Origins are validated at install time and re-validated here as defense in
|
||||||
* depth — any malformed value is dropped so a corrupted registry can never
|
* depth - any malformed value is dropped so a corrupted registry can never
|
||||||
* inject arbitrary CSP fragments.
|
* inject arbitrary CSP fragments.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ const CACHE_TTL_MS = 5_000;
|
|||||||
* the server-side registry, deduped and validated.
|
* the server-side registry, deduped and validated.
|
||||||
*
|
*
|
||||||
* Returns an empty array on any failure (missing file, parse error, …) so
|
* Returns an empty array on any failure (missing file, parse error, …) so
|
||||||
* a broken registry only ever shrinks the CSP — never widens it.
|
* a broken registry only ever shrinks the CSP - never widens it.
|
||||||
*/
|
*/
|
||||||
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface FeatureGates {
|
|||||||
folderIconsEnabled: boolean;
|
folderIconsEnabled: boolean;
|
||||||
hoverActionsConfigEnabled: boolean;
|
hoverActionsConfigEnabled: boolean;
|
||||||
filesEnabled: boolean;
|
filesEnabled: boolean;
|
||||||
|
contactsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
|||||||
folderIconsEnabled: true,
|
folderIconsEnabled: true,
|
||||||
hoverActionsConfigEnabled: true,
|
hoverActionsConfigEnabled: true,
|
||||||
filesEnabled: true,
|
filesEnabled: true,
|
||||||
|
contactsEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemePolicy {
|
export interface ThemePolicy {
|
||||||
|
|||||||
+14
-1
@@ -7,8 +7,21 @@ export interface OAuthMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const CACHE_MAX_ENTRIES = 64;
|
||||||
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
|
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
|
||||||
|
|
||||||
|
function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
|
||||||
|
// Bound the cache so callers that can supply arbitrary serverUrl values
|
||||||
|
// (e.g. unauthenticated routes that fall back to user input) cannot
|
||||||
|
// exhaust memory. Map preserves insertion order, so the oldest entry is
|
||||||
|
// always the first one yielded by keys().
|
||||||
|
if (metadataCache.size >= CACHE_MAX_ENTRIES) {
|
||||||
|
const oldest = metadataCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) metadataCache.delete(oldest);
|
||||||
|
}
|
||||||
|
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
|
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
|
||||||
const cached = metadataCache.get(serverUrl);
|
const cached = metadataCache.get(serverUrl);
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
|
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
|
||||||
@@ -38,7 +51,7 @@ export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata |
|
|||||||
revocation_endpoint: data.revocation_endpoint,
|
revocation_endpoint: data.revocation_endpoint,
|
||||||
end_session_endpoint: data.end_session_endpoint,
|
end_session_endpoint: data.end_session_endpoint,
|
||||||
};
|
};
|
||||||
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
|
rememberMetadata(serverUrl, metadata);
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
errors.push(`${url} response missing required endpoints`);
|
errors.push(`${url} response missing required endpoints`);
|
||||||
|
|||||||
@@ -365,6 +365,18 @@ export const themeHooks = {
|
|||||||
onThemeChange: new HookBus(),
|
onThemeChange: new HookBus(),
|
||||||
onCustomThemeChange: new HookBus(),
|
onCustomThemeChange: new HookBus(),
|
||||||
onLocaleChange: new HookBus(),
|
onLocaleChange: new HookBus(),
|
||||||
|
/**
|
||||||
|
* Transform hook fired immediately before a theme's compiled CSS is
|
||||||
|
* injected into the document.
|
||||||
|
*
|
||||||
|
* handler(css: string, ctx: { themeId: string | null; variant: 'light' | 'dark' }): string | undefined
|
||||||
|
*
|
||||||
|
* Return a new CSS string to override what gets injected, or `undefined`
|
||||||
|
* to pass through unchanged. Use this to inject extra `@font-face` rules,
|
||||||
|
* patch a third-party theme's variables for accessibility, or implement
|
||||||
|
* site-wide design-token overrides.
|
||||||
|
*/
|
||||||
|
onThemeBeforeApply: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// §7.15 Toast Hooks
|
// §7.15 Toast Hooks
|
||||||
|
|||||||
+18
-1
@@ -1,9 +1,11 @@
|
|||||||
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
|
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
|
||||||
|
|
||||||
const DB_NAME = 'bulwark-plugins';
|
const DB_NAME = 'bulwark-plugins';
|
||||||
const DB_VERSION = 1;
|
// Bumped to 2 to add the theme-skin store; existing stores are preserved.
|
||||||
|
const DB_VERSION = 2;
|
||||||
const STORE_PLUGINS = 'plugin-code';
|
const STORE_PLUGINS = 'plugin-code';
|
||||||
const STORE_THEMES = 'theme-css';
|
const STORE_THEMES = 'theme-css';
|
||||||
|
const STORE_THEME_SKINS = 'theme-skin';
|
||||||
const STORE_PREVIEWS = 'previews';
|
const STORE_PREVIEWS = 'previews';
|
||||||
|
|
||||||
function openDB(): Promise<IDBDatabase> {
|
function openDB(): Promise<IDBDatabase> {
|
||||||
@@ -18,6 +20,9 @@ function openDB(): Promise<IDBDatabase> {
|
|||||||
if (!db.objectStoreNames.contains(STORE_THEMES)) {
|
if (!db.objectStoreNames.contains(STORE_THEMES)) {
|
||||||
db.createObjectStore(STORE_THEMES);
|
db.createObjectStore(STORE_THEMES);
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains(STORE_THEME_SKINS)) {
|
||||||
|
db.createObjectStore(STORE_THEME_SKINS);
|
||||||
|
}
|
||||||
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
|
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
|
||||||
db.createObjectStore(STORE_PREVIEWS);
|
db.createObjectStore(STORE_PREVIEWS);
|
||||||
}
|
}
|
||||||
@@ -83,6 +88,18 @@ export const pluginStorage = {
|
|||||||
await deleteItem(STORE_THEMES, themeId);
|
await deleteItem(STORE_THEMES, themeId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Theme skin CSS - separate store so it can be present/absent independently
|
||||||
|
// of the colour-token CSS (e.g. some v2 themes ship colours only).
|
||||||
|
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
|
||||||
|
await putItem(STORE_THEME_SKINS, themeId, skin);
|
||||||
|
},
|
||||||
|
async getThemeSkin(themeId: string): Promise<string | null> {
|
||||||
|
return getItem<string>(STORE_THEME_SKINS, themeId);
|
||||||
|
},
|
||||||
|
async deleteThemeSkin(themeId: string): Promise<void> {
|
||||||
|
await deleteItem(STORE_THEME_SKINS, themeId);
|
||||||
|
},
|
||||||
|
|
||||||
// Preview images (stored as data URIs)
|
// Preview images (stored as data URIs)
|
||||||
async savePreview(id: string, dataUri: string): Promise<void> {
|
async savePreview(id: string, dataUri: string): Promise<void> {
|
||||||
await putItem(STORE_PREVIEWS, id, dataUri);
|
await putItem(STORE_PREVIEWS, id, dataUri);
|
||||||
|
|||||||
+76
-2
@@ -11,6 +11,41 @@ export type ThemeVariant = 'light' | 'dark';
|
|||||||
|
|
||||||
// ─── Manifests ───────────────────────────────────────────────
|
// ─── Manifests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced theme fields ("Theme API v2"). All optional and additive - a
|
||||||
|
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
|
||||||
|
*
|
||||||
|
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
|
||||||
|
* `radii`/`typography` is present), the theme compiler runs at install time
|
||||||
|
* and produces a single CSS string from the structured fields, optionally
|
||||||
|
* concatenated with a hand-written `theme.css` for fine-grained overrides.
|
||||||
|
*/
|
||||||
|
export interface ThemeTokenSet {
|
||||||
|
/** Tokens applied regardless of variant (emitted into `:root`). */
|
||||||
|
common?: Record<string, string>;
|
||||||
|
/** Tokens applied in light mode (emitted into `:root`). */
|
||||||
|
light?: Record<string, string>;
|
||||||
|
/** Tokens applied in dark mode (emitted into `.dark`). */
|
||||||
|
dark?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThemeDensity = 'compact' | 'normal' | 'touch';
|
||||||
|
|
||||||
|
export interface ThemeRadii {
|
||||||
|
sm?: string;
|
||||||
|
md?: string;
|
||||||
|
lg?: string;
|
||||||
|
xl?: string;
|
||||||
|
full?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeTypography {
|
||||||
|
fontSans?: string;
|
||||||
|
fontMono?: string;
|
||||||
|
fontDisplay?: string;
|
||||||
|
baseFontSize?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThemeManifest {
|
export interface ThemeManifest {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,6 +56,22 @@ export interface ThemeManifest {
|
|||||||
preview?: string;
|
preview?: string;
|
||||||
variants: ThemeVariant[];
|
variants: ThemeVariant[];
|
||||||
minAppVersion?: string;
|
minAppVersion?: string;
|
||||||
|
|
||||||
|
// ─── Advanced (Theme API v2) ─────────────────────────────────
|
||||||
|
/** Theme API version. Defaults to 1 (raw-CSS only). */
|
||||||
|
apiVersion?: 1 | 2;
|
||||||
|
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
|
||||||
|
extends?: string;
|
||||||
|
/** Structured colour tokens - compiled into CSS at install time. */
|
||||||
|
tokens?: ThemeTokenSet;
|
||||||
|
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
|
||||||
|
derive?: boolean;
|
||||||
|
/** Default UI density preset (compact / normal / touch). */
|
||||||
|
density?: ThemeDensity;
|
||||||
|
/** Border-radius scale, emitted as `--radius-*` vars. */
|
||||||
|
radii?: ThemeRadii;
|
||||||
|
/** Font stacks + base size, emitted as `--font-*` vars. */
|
||||||
|
typography?: ThemeTypography;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginManifest {
|
export interface PluginManifest {
|
||||||
@@ -70,12 +121,29 @@ export interface InstalledTheme {
|
|||||||
author: string;
|
author: string;
|
||||||
description: string;
|
description: string;
|
||||||
preview?: string; // data: URI or blob URL
|
preview?: string; // data: URI or blob URL
|
||||||
css: string; // raw CSS text
|
css: string; // compiled CSS text - what gets injected
|
||||||
|
/**
|
||||||
|
* Optional "skin" CSS shipped by Theme API v2 themes that need to restyle
|
||||||
|
* actual UI components (toolbars, lists, buttons, etc.) - not just colour
|
||||||
|
* tokens. Injected into a separate `<style>` tag so it can be stripped
|
||||||
|
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
|
||||||
|
* lifecycle as `css` to keep localStorage small.
|
||||||
|
*/
|
||||||
|
skin?: string;
|
||||||
variants: ThemeVariant[];
|
variants: ThemeVariant[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
builtIn: boolean;
|
builtIn: boolean;
|
||||||
managed?: boolean;
|
managed?: boolean;
|
||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
|
|
||||||
|
// ─── Advanced (Theme API v2) ─ carried over from the manifest ─
|
||||||
|
apiVersion?: 1 | 2;
|
||||||
|
extends?: string;
|
||||||
|
tokens?: ThemeTokenSet;
|
||||||
|
derive?: boolean;
|
||||||
|
density?: ThemeDensity;
|
||||||
|
radii?: ThemeRadii;
|
||||||
|
typography?: ThemeTypography;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstalledPlugin {
|
export interface InstalledPlugin {
|
||||||
@@ -527,7 +595,13 @@ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'
|
|||||||
// ─── Validation ──────────────────────────────────────────────
|
// ─── Validation ──────────────────────────────────────────────
|
||||||
|
|
||||||
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
|
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB
|
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
|
||||||
|
/**
|
||||||
|
* Maximum size of an individual `skin.css` payload after extraction.
|
||||||
|
* Skins are component-level CSS, not images - anything bigger than this is
|
||||||
|
* almost certainly bundling assets the validator will refuse anyway.
|
||||||
|
*/
|
||||||
|
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
|
||||||
|
|
||||||
export const ALLOWED_PLUGIN_FILES = new Set([
|
export const ALLOWED_PLUGIN_FILES = new Set([
|
||||||
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
|
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
|
||||||
|
|||||||
+91
-14
@@ -8,9 +8,11 @@ import {
|
|||||||
ALL_PERMISSIONS,
|
ALL_PERMISSIONS,
|
||||||
MAX_PLUGIN_SIZE,
|
MAX_PLUGIN_SIZE,
|
||||||
MAX_THEME_SIZE,
|
MAX_THEME_SIZE,
|
||||||
|
MAX_THEME_SKIN_BYTES,
|
||||||
ALLOWED_PLUGIN_FILES,
|
ALLOWED_PLUGIN_FILES,
|
||||||
} from './plugin-types';
|
} from './plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from './theme-loader';
|
import { sanitizeThemeCSS, sanitizeSkinCSS, validateThemeCSSSafety } from './theme-loader';
|
||||||
|
import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler';
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
@@ -21,6 +23,11 @@ export interface ValidationResult {
|
|||||||
export interface ThemeExtractionResult extends ValidationResult {
|
export interface ThemeExtractionResult extends ValidationResult {
|
||||||
manifest: ThemeManifest | null;
|
manifest: ThemeManifest | null;
|
||||||
css: string;
|
css: string;
|
||||||
|
/**
|
||||||
|
* Optional skin CSS - component-level overrides extracted from `skin.css`.
|
||||||
|
* Only populated for Theme API v2 manifests; v1 themes ignore the file.
|
||||||
|
*/
|
||||||
|
skin: string | null;
|
||||||
preview: string | null; // data URI
|
preview: string | null; // data URI
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +70,29 @@ function validateThemeManifest(manifest: Record<string, unknown>): { result: The
|
|||||||
if (!valid) errors.push('Variants must be "light" or "dark"');
|
if (!valid) errors.push('Variants must be "light" or "dark"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Theme API v2 fields (all optional) ──
|
||||||
|
if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) {
|
||||||
|
errors.push('"apiVersion" must be 1 or 2 if present');
|
||||||
|
}
|
||||||
|
if (manifest.extends !== undefined && typeof manifest.extends !== 'string') {
|
||||||
|
errors.push('"extends" must be a string (the parent theme id)');
|
||||||
|
}
|
||||||
|
if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) {
|
||||||
|
errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps');
|
||||||
|
}
|
||||||
|
if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) {
|
||||||
|
errors.push('"density" must be "compact", "normal", or "touch"');
|
||||||
|
}
|
||||||
|
if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') {
|
||||||
|
errors.push('"derive" must be a boolean');
|
||||||
|
}
|
||||||
|
if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) {
|
||||||
|
errors.push('"radii" must be an object');
|
||||||
|
}
|
||||||
|
if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) {
|
||||||
|
errors.push('"typography" must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.length > 0) return { result: null, errors };
|
if (errors.length > 0) return { result: null, errors };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -157,7 +187,12 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
|||||||
|
|
||||||
// Size check
|
// Size check
|
||||||
if (file.size > MAX_THEME_SIZE) {
|
if (file.size > MAX_THEME_SIZE) {
|
||||||
return { valid: false, errors: ['Theme ZIP exceeds 1 MB size limit'], warnings: [], manifest: null, css: '', preview: null };
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [`Theme ZIP exceeds ${Math.round(MAX_THEME_SIZE / (1024 * 1024))} MB size limit`],
|
||||||
|
warnings: [],
|
||||||
|
manifest: null, css: '', skin: null, preview: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let zip: JSZip;
|
let zip: JSZip;
|
||||||
@@ -165,7 +200,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
|||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
zip = await JSZip.loadAsync(buffer);
|
zip = await JSZip.loadAsync(buffer);
|
||||||
} catch {
|
} catch {
|
||||||
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', preview: null };
|
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = findZipRoot(zip);
|
const root = findZipRoot(zip);
|
||||||
@@ -173,7 +208,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
|||||||
// Read manifest
|
// Read manifest
|
||||||
const manifestFile = zip.file(root + 'manifest.json');
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
if (!manifestFile) {
|
if (!manifestFile) {
|
||||||
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', preview: null };
|
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifestData: Record<string, unknown>;
|
let manifestData: Record<string, unknown>;
|
||||||
@@ -181,28 +216,49 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
|||||||
const raw = await manifestFile.async('string');
|
const raw = await manifestFile.async('string');
|
||||||
manifestData = JSON.parse(raw);
|
manifestData = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', preview: null };
|
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData);
|
const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData);
|
||||||
errors.push(...manifestErrors);
|
errors.push(...manifestErrors);
|
||||||
if (!manifest) {
|
if (!manifest) {
|
||||||
return { valid: false, errors, warnings, manifest: null, css: '', preview: null };
|
return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read theme.css
|
// Read theme.css - required for v1 themes, optional when the manifest
|
||||||
|
// declares Theme API v2 fields (tokens/extends/derive/density/radii/typography),
|
||||||
|
// since the compiler can produce CSS purely from the manifest.
|
||||||
const cssFile = zip.file(root + 'theme.css');
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
if (!cssFile) {
|
const isAdvanced = isAdvancedManifest(manifest);
|
||||||
|
|
||||||
|
let userCSS = '';
|
||||||
|
if (cssFile) {
|
||||||
|
userCSS = await cssFile.async('string');
|
||||||
|
const safety = validateThemeCSSSafety(userCSS);
|
||||||
|
if (!safety.valid) {
|
||||||
|
// Sanitize instead of rejecting
|
||||||
|
const sanitized = sanitizeThemeCSS(userCSS);
|
||||||
|
userCSS = sanitized.css;
|
||||||
|
warnings.push(...sanitized.warnings);
|
||||||
|
}
|
||||||
|
} else if (!isAdvanced) {
|
||||||
errors.push('Missing theme.css');
|
errors.push('Missing theme.css');
|
||||||
return { valid: false, errors, warnings, manifest, css: '', preview: null };
|
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawCSS = await cssFile.async('string');
|
// Compile advanced tokens into CSS (for v2 manifests). The compiled output
|
||||||
|
// is concatenated with any user-supplied theme.css for fine-grained overrides.
|
||||||
|
let rawCSS = userCSS;
|
||||||
|
if (isAdvanced) {
|
||||||
|
const compiled = compileAdvancedTheme(manifest, { userCSS });
|
||||||
|
if (compiled.errors.length > 0) {
|
||||||
|
errors.push(...compiled.errors);
|
||||||
|
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
|
||||||
|
}
|
||||||
|
warnings.push(...compiled.warnings);
|
||||||
|
rawCSS = compiled.css;
|
||||||
|
|
||||||
// Validate CSS safety
|
// Run sanitizer over the final compiled output as a defence-in-depth check.
|
||||||
const safety = validateThemeCSSSafety(rawCSS);
|
|
||||||
if (!safety.valid) {
|
|
||||||
// Sanitize instead of rejecting
|
|
||||||
const sanitized = sanitizeThemeCSS(rawCSS);
|
const sanitized = sanitizeThemeCSS(rawCSS);
|
||||||
rawCSS = sanitized.css;
|
rawCSS = sanitized.css;
|
||||||
warnings.push(...sanitized.warnings);
|
warnings.push(...sanitized.warnings);
|
||||||
@@ -222,12 +278,33 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read skin.css if present (Theme API v2 only). Skins target real
|
||||||
|
// component selectors and bypass the strict :root/.dark selector check -
|
||||||
|
// they still go through the dangerous-pattern sanitizer.
|
||||||
|
let skin: string | null = null;
|
||||||
|
const skinFile = zip.file(root + 'skin.css');
|
||||||
|
if (skinFile) {
|
||||||
|
if (!isAdvanced) {
|
||||||
|
warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin');
|
||||||
|
} else {
|
||||||
|
const rawSkin = await skinFile.async('string');
|
||||||
|
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
|
||||||
|
warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`);
|
||||||
|
} else {
|
||||||
|
const sanitized = sanitizeSkinCSS(rawSkin);
|
||||||
|
skin = sanitized.css;
|
||||||
|
warnings.push(...sanitized.warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: errors.length === 0,
|
valid: errors.length === 0,
|
||||||
errors,
|
errors,
|
||||||
warnings,
|
warnings,
|
||||||
manifest,
|
manifest,
|
||||||
css: rawCSS,
|
css: rawCSS,
|
||||||
|
skin,
|
||||||
preview,
|
preview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
// Block telemetry endpoints from pointing at internal/loopback addresses.
|
||||||
|
// Required because the admin UI lets an authenticated admin set an arbitrary
|
||||||
|
// URL; without this an attacker with a session (or a hostile admin in a
|
||||||
|
// multi-tenant deploy) could redirect heartbeats at internal hosts.
|
||||||
|
//
|
||||||
|
// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass - useful only for local
|
||||||
|
// dev where the collector is on the loopback.
|
||||||
|
|
||||||
|
const PRIVATE_V4: RegExp[] = [
|
||||||
|
/^0\./, // 0.0.0.0/8
|
||||||
|
/^10\./, // 10.0.0.0/8
|
||||||
|
/^127\./, // loopback
|
||||||
|
/^169\.254\./, // link-local + cloud metadata
|
||||||
|
/^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12
|
||||||
|
/^192\.168\./, // 192.168.0.0/16
|
||||||
|
/^192\.0\.0\./, // IETF reserved
|
||||||
|
/^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15
|
||||||
|
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT
|
||||||
|
/^22[4-9]\./, // 224.0.0.0/4 multicast
|
||||||
|
/^23\d\./,
|
||||||
|
/^2[4-5]\d\./, // 240.0.0.0/4 reserved
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPrivateV4(ip: string): boolean {
|
||||||
|
return PRIVATE_V4.some((re) => re.test(ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateV6(ip: string): boolean {
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
if (lower === '::1' || lower === '::') return true;
|
||||||
|
if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; // fe80::/10 link-local
|
||||||
|
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // fc00::/7 ULA
|
||||||
|
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (mapped) return isPrivateV4(mapped[1]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrivateAddress(ip: string): boolean {
|
||||||
|
const family = isIP(ip);
|
||||||
|
if (family === 4) return isPrivateV4(ip);
|
||||||
|
if (family === 6) return isPrivateV6(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAD_HOSTS = new Set([
|
||||||
|
'localhost',
|
||||||
|
'localhost.localdomain',
|
||||||
|
'ip6-localhost',
|
||||||
|
'ip6-loopback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function bypassEnabled(): boolean {
|
||||||
|
return process.env.BULWARK_TELEMETRY_ALLOW_PRIVATE === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EndpointCheck = { ok: true } | { ok: false; reason: string };
|
||||||
|
|
||||||
|
// Sync URL/host shape check. Catches the obvious cases without DNS.
|
||||||
|
export function validateEndpointUrl(raw: string): EndpointCheck {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: 'invalid URL' };
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return { ok: false, reason: 'must be http(s)://' };
|
||||||
|
}
|
||||||
|
if (bypassEnabled()) return { ok: true };
|
||||||
|
|
||||||
|
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
if (!host) return { ok: false, reason: 'host required' };
|
||||||
|
if (BAD_HOSTS.has(host)) {
|
||||||
|
return { ok: false, reason: 'localhost endpoints are not allowed' };
|
||||||
|
}
|
||||||
|
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) {
|
||||||
|
return { ok: false, reason: 'private TLDs are not allowed' };
|
||||||
|
}
|
||||||
|
if (isIP(host) && isPrivateAddress(host)) {
|
||||||
|
return { ok: false, reason: 'private/loopback IP is not allowed' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async check that additionally resolves DNS hostnames. Use this on
|
||||||
|
// set-endpoint AND immediately before fetch to defeat DNS-rebinding tricks
|
||||||
|
// where a hostname resolves to a public IP at validation time and a private
|
||||||
|
// one at fetch time.
|
||||||
|
export async function resolveEndpointAllowed(raw: string): Promise<EndpointCheck> {
|
||||||
|
const initial = validateEndpointUrl(raw);
|
||||||
|
if (!initial.ok) return initial;
|
||||||
|
if (bypassEnabled()) return { ok: true };
|
||||||
|
|
||||||
|
const host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
if (isIP(host)) return { ok: true };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const addrs = await lookup(host, { all: true });
|
||||||
|
for (const a of addrs) {
|
||||||
|
if (isPrivateAddress(a.address)) {
|
||||||
|
return { ok: false, reason: `host ${host} resolves to private address ${a.address}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch {
|
||||||
|
// Don't block on transient DNS failures - fetch will fail loudly anyway,
|
||||||
|
// and we don't want to lock admins out of their config when the resolver
|
||||||
|
// is flaky. The literal-IP check above already covers the direct-attack
|
||||||
|
// case.
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export { startScheduler, stopScheduler, reschedule, sendOnce } from './sender';
|
||||||
|
export { buildPayload, markProcessStart } from './payload';
|
||||||
|
export {
|
||||||
|
loadState, saveState, getInstanceId, effectiveConsent,
|
||||||
|
} from './state';
|
||||||
|
export { recordLogin, getLoginCounts } from './login-tracker';
|
||||||
|
export {
|
||||||
|
validateEndpointUrl, resolveEndpointAllowed, isPrivateAddress,
|
||||||
|
} from './endpoint-guard';
|
||||||
|
export type { EndpointCheck } from './endpoint-guard';
|
||||||
|
export type {
|
||||||
|
TelemetryPayload, TelemetryStateFile, ConsentState,
|
||||||
|
Platform, OsFamily, CountBucket, TelemetryFeatures,
|
||||||
|
} from './types';
|
||||||
|
export { DEFAULT_ENDPOINT } from './types';
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getInstanceId } from './state';
|
||||||
|
|
||||||
|
// We never store usernames or server URLs in the clear. Each login is
|
||||||
|
// recorded as HMAC-SHA256(username + '@' + serverUrl, instance_id), so the
|
||||||
|
// file on disk cannot be cross-correlated with any other instance and is
|
||||||
|
// not PII even if leaked.
|
||||||
|
|
||||||
|
interface LoginRecord {
|
||||||
|
id: string;
|
||||||
|
lastLoginAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginsFile {
|
||||||
|
records: LoginRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
let cache: LoginsFile | null = null;
|
||||||
|
|
||||||
|
function getDir(): string {
|
||||||
|
return process.env.TELEMETRY_DATA_DIR || path.join(process.cwd(), 'data', 'telemetry');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginsPath(): string {
|
||||||
|
return path.join(getDir(), 'logins.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(): Promise<void> {
|
||||||
|
const dir = getDir();
|
||||||
|
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(): Promise<LoginsFile> {
|
||||||
|
if (cache) return cache;
|
||||||
|
try {
|
||||||
|
const raw = await readFile(loginsPath(), 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<LoginsFile>;
|
||||||
|
cache = Array.isArray(parsed?.records) ? { records: parsed.records as LoginRecord[] } : { records: [] };
|
||||||
|
} catch {
|
||||||
|
cache = { records: [] };
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFile(file: LoginsFile): Promise<void> {
|
||||||
|
await ensureDir();
|
||||||
|
cache = file;
|
||||||
|
const tmp = loginsPath() + '.tmp';
|
||||||
|
await writeFile(tmp, JSON.stringify(file), 'utf8');
|
||||||
|
await rename(tmp, loginsPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServer(serverUrl: string): string {
|
||||||
|
return serverUrl.trim().replace(/\/+$/, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashIdentity(username: string, serverUrl: string): Promise<string> {
|
||||||
|
const instanceId = await getInstanceId();
|
||||||
|
const subject = `${username.trim().toLowerCase()}@${normalizeServer(serverUrl)}`;
|
||||||
|
return createHmac('sha256', instanceId).update(subject).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a successful login. Best-effort; never throws. Updates the
|
||||||
|
* existing record's timestamp if the same identity has logged in before,
|
||||||
|
* otherwise appends a new record. Records older than the retention window
|
||||||
|
* are pruned on every write.
|
||||||
|
*/
|
||||||
|
export async function recordLogin(username: string, serverUrl: string): Promise<void> {
|
||||||
|
if (!username || !serverUrl) return;
|
||||||
|
try {
|
||||||
|
const id = await hashIdentity(username, serverUrl);
|
||||||
|
const file = await loadFile();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const cutoff = Date.now() - RETENTION_MS;
|
||||||
|
const next: LoginRecord[] = [];
|
||||||
|
let updated = false;
|
||||||
|
for (const rec of file.records) {
|
||||||
|
const ts = new Date(rec.lastLoginAt).getTime();
|
||||||
|
if (Number.isNaN(ts) || ts < cutoff) continue;
|
||||||
|
if (rec.id === id) {
|
||||||
|
next.push({ id, lastLoginAt: now });
|
||||||
|
updated = true;
|
||||||
|
} else {
|
||||||
|
next.push(rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!updated) next.push({ id, lastLoginAt: now });
|
||||||
|
await saveFile({ records: next });
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug?.('telemetry: recordLogin failed', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total distinct accounts seen in the 90-day retention window, plus those
|
||||||
|
* with a login in the last 7 days.
|
||||||
|
*/
|
||||||
|
export async function getLoginCounts(): Promise<{ total: number; active7d: number }> {
|
||||||
|
try {
|
||||||
|
const file = await loadFile();
|
||||||
|
const cutoff = Date.now() - RETENTION_MS;
|
||||||
|
const sevenAgo = Date.now() - SEVEN_DAYS_MS;
|
||||||
|
let total = 0;
|
||||||
|
let active7d = 0;
|
||||||
|
for (const rec of file.records) {
|
||||||
|
const ts = new Date(rec.lastLoginAt).getTime();
|
||||||
|
if (Number.isNaN(ts) || ts < cutoff) continue;
|
||||||
|
total++;
|
||||||
|
if (ts >= sevenAgo) active7d++;
|
||||||
|
}
|
||||||
|
return { total, active7d };
|
||||||
|
} catch {
|
||||||
|
return { total: 0, active7d: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { resolveEndpointAllowed } from './endpoint-guard';
|
||||||
|
import { getInstanceId } from './state';
|
||||||
|
import { getLoginCounts } from './login-tracker';
|
||||||
|
import type {
|
||||||
|
TelemetryPayload,
|
||||||
|
TelemetryFeatures,
|
||||||
|
Platform,
|
||||||
|
OsFamily,
|
||||||
|
CountBucket,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
let processStartedAt = Date.now();
|
||||||
|
export function markProcessStart(): void {
|
||||||
|
processStartedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPackage(): { version: string; build: string | null } {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||||
|
) as { version?: string };
|
||||||
|
return { version: pkg.version ?? '0.0.0', build: process.env.BULWARK_BUILD ?? 'release' };
|
||||||
|
} catch {
|
||||||
|
return { version: '0.0.0', build: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPlatform(): Platform {
|
||||||
|
if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s';
|
||||||
|
// /.dockerenv is the standard Docker container marker.
|
||||||
|
try {
|
||||||
|
readFileSync('/.dockerenv');
|
||||||
|
return 'docker';
|
||||||
|
} catch { /* not in docker */ }
|
||||||
|
return 'bare';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOs(): OsFamily {
|
||||||
|
switch (process.platform) {
|
||||||
|
case 'linux': return 'linux';
|
||||||
|
case 'darwin': return 'darwin';
|
||||||
|
case 'win32': return 'windows';
|
||||||
|
default: return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bucketCount(n: number): CountBucket {
|
||||||
|
if (n <= 0) return '0';
|
||||||
|
if (n === 1) return '1';
|
||||||
|
if (n <= 5) return '2-5';
|
||||||
|
if (n <= 10) return '6-10';
|
||||||
|
if (n <= 50) return '11-50';
|
||||||
|
if (n <= 200) return '51-200';
|
||||||
|
return '201+';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFeatures(): Promise<TelemetryFeatures> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const gates = configManager.getPolicy().features;
|
||||||
|
const cfg = configManager.getAll();
|
||||||
|
return {
|
||||||
|
// Booleans only. We read whether a feature is enabled - never any
|
||||||
|
// config value beyond a presence check.
|
||||||
|
calendar: gates.calendarTasksEnabled === true,
|
||||||
|
contacts: gates.contactsEnabled === true,
|
||||||
|
files: gates.filesEnabled === true,
|
||||||
|
extensions: gates.pluginsEnabled === true,
|
||||||
|
oauth_enabled: cfg['oauthEnabled'] === true,
|
||||||
|
smime_enabled: gates.smimeEnabled === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALWART_VERSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
let stalwartVersionCache: { version: string | null; fetchedAt: number } | null = null;
|
||||||
|
|
||||||
|
// Stalwart returns the version in the Server response header
|
||||||
|
// (e.g. "Stalwart Mail Server v0.16.0"). The /.well-known/jmap endpoint
|
||||||
|
// requires auth, but the header is on the 401 response too, so an
|
||||||
|
// unauthenticated GET is enough. Cached for a day to avoid hammering
|
||||||
|
// the JMAP server on every payload preview.
|
||||||
|
async function detectStalwartVersion(): Promise<string | null> {
|
||||||
|
if (process.env.STALWART_VERSION) return process.env.STALWART_VERSION;
|
||||||
|
if (stalwartVersionCache &&
|
||||||
|
Date.now() - stalwartVersionCache.fetchedAt < STALWART_VERSION_TTL_MS) {
|
||||||
|
return stalwartVersionCache.version;
|
||||||
|
}
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const serverUrl = configManager.get<string>('jmapServerUrl', '').trim();
|
||||||
|
if (!serverUrl) {
|
||||||
|
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const wellKnown = `${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`;
|
||||||
|
// Reuse the SSRF guard so a misconfigured JMAP_SERVER_URL pointing at an
|
||||||
|
// internal host doesn't get probed from telemetry context either.
|
||||||
|
const guard = await resolveEndpointAllowed(wellKnown);
|
||||||
|
if (!guard.ok) {
|
||||||
|
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(wellKnown, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
});
|
||||||
|
const server = res.headers.get('server') ?? '';
|
||||||
|
const m = server.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
|
||||||
|
const version = m?.[1] ?? null;
|
||||||
|
stalwartVersionCache = { version, fetchedAt: Date.now() };
|
||||||
|
return version;
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug?.('telemetry: stalwart version probe failed', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account counts come from the local login tracker, which records a per-
|
||||||
|
// instance HMAC of every successful login plus the timestamp. Total = unique
|
||||||
|
// identities seen in the last 90 days; active7d = identities with a login in
|
||||||
|
// the last 7 days.
|
||||||
|
|
||||||
|
async function countExtensions(): Promise<{ extensions: number; themes: number }> {
|
||||||
|
try {
|
||||||
|
const { getPluginRegistry, getThemeRegistry } = await import('@/lib/admin/plugin-registry');
|
||||||
|
const [plugins, themes] = await Promise.all([getPluginRegistry(), getThemeRegistry()]);
|
||||||
|
return {
|
||||||
|
extensions: plugins.plugins.length,
|
||||||
|
themes: themes.themes.length,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { extensions: 0, themes: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPayload(): Promise<TelemetryPayload> {
|
||||||
|
const instance_id = await getInstanceId();
|
||||||
|
const { version, build } = readPackage();
|
||||||
|
const features = await readFeatures();
|
||||||
|
const accounts = await getLoginCounts();
|
||||||
|
const exts = await countExtensions();
|
||||||
|
const stalwart_version = await detectStalwartVersion();
|
||||||
|
const uptime_days = Math.min(
|
||||||
|
365,
|
||||||
|
Math.floor((Date.now() - processStartedAt) / 86_400_000),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema: '1',
|
||||||
|
instance_id,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
version,
|
||||||
|
build,
|
||||||
|
platform: detectPlatform(),
|
||||||
|
node_version: process.versions.node,
|
||||||
|
os_family: detectOs(),
|
||||||
|
stalwart_version,
|
||||||
|
features,
|
||||||
|
counts: {
|
||||||
|
accounts: bucketCount(accounts.total),
|
||||||
|
accounts_active_7d: bucketCount(accounts.active7d),
|
||||||
|
extensions_installed: exts.extensions,
|
||||||
|
themes_installed: exts.themes,
|
||||||
|
},
|
||||||
|
uptime_days,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { effectiveConsent, endpointEnabled, loadState, saveState } from './state';
|
||||||
|
import { buildPayload } from './payload';
|
||||||
|
import { resolveEndpointAllowed } from './endpoint-guard';
|
||||||
|
import { DEFAULT_ENDPOINT } from './types';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours
|
||||||
|
const FIRST_DELAY_MS = 60 * 60 * 1000; // 1 hour after consent
|
||||||
|
|
||||||
|
let currentTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
function jitteredDelay(base: number): number {
|
||||||
|
const j = (Math.random() * 2 - 1) * JITTER_MS;
|
||||||
|
return Math.max(60_000, base + j);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendOnce(opts?: { reason?: string }): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const { consent, source, state } = await effectiveConsent();
|
||||||
|
if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` };
|
||||||
|
const endpoint = state.endpoint || DEFAULT_ENDPOINT;
|
||||||
|
if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' };
|
||||||
|
|
||||||
|
// Re-check at fetch time: defeats DNS rebinding, and catches the case
|
||||||
|
// where state.json was edited out-of-band to bypass the admin API.
|
||||||
|
const guard = await resolveEndpointAllowed(endpoint);
|
||||||
|
if (!guard.ok) {
|
||||||
|
logger.warn('telemetry: endpoint blocked', { reason: guard.reason });
|
||||||
|
return { ok: false, error: `endpoint blocked: ${guard.reason}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await buildPayload();
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
const ok = res.ok;
|
||||||
|
if (ok) {
|
||||||
|
const next = await loadState();
|
||||||
|
next.lastSentAt = new Date().toISOString();
|
||||||
|
await saveState(next);
|
||||||
|
}
|
||||||
|
logger.info('telemetry: heartbeat', {
|
||||||
|
ok, status: res.status, reason: opts?.reason ?? 'scheduled',
|
||||||
|
});
|
||||||
|
return { ok, status: res.status };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.warn('telemetry: heartbeat failed', { error: msg });
|
||||||
|
return { ok: false, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleNext(delayMs: number): Promise<void> {
|
||||||
|
if (currentTimer) clearTimeout(currentTimer);
|
||||||
|
const at = new Date(Date.now() + delayMs).toISOString();
|
||||||
|
const state = await loadState();
|
||||||
|
state.nextScheduledAt = at;
|
||||||
|
await saveState(state);
|
||||||
|
currentTimer = setTimeout(() => { void tick(); }, delayMs);
|
||||||
|
// Don't keep the process alive just for this.
|
||||||
|
currentTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick(): Promise<void> {
|
||||||
|
await sendOnce({ reason: 'scheduled' });
|
||||||
|
await scheduleNext(jitteredDelay(DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from instrumentation. Idempotent.
|
||||||
|
export async function startScheduler(): Promise<void> {
|
||||||
|
const { consent } = await effectiveConsent();
|
||||||
|
if (consent !== 'on') {
|
||||||
|
logger.info('telemetry: scheduler not started', { consent });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = await loadState();
|
||||||
|
// If we have a next-scheduled time in the future use it; otherwise schedule
|
||||||
|
// FIRST_DELAY_MS out. This means after a restart we don't fire immediately.
|
||||||
|
let delay = FIRST_DELAY_MS;
|
||||||
|
if (state.nextScheduledAt) {
|
||||||
|
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
|
||||||
|
if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS);
|
||||||
|
}
|
||||||
|
await scheduleNext(delay);
|
||||||
|
logger.info('telemetry: scheduler started', {
|
||||||
|
nextInMs: delay,
|
||||||
|
endpoint: state.endpoint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopScheduler(): Promise<void> {
|
||||||
|
if (currentTimer) clearTimeout(currentTimer);
|
||||||
|
currentTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when consent flips on/off via the UI.
|
||||||
|
export async function reschedule(): Promise<void> {
|
||||||
|
await stopScheduler();
|
||||||
|
await startScheduler();
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import type { TelemetryStateFile, ConsentState } from './types';
|
||||||
|
import { DEFAULT_ENDPOINT } from './types';
|
||||||
|
|
||||||
|
function getDir(): string {
|
||||||
|
return process.env.TELEMETRY_DATA_DIR ||
|
||||||
|
path.join(process.cwd(), 'data', 'telemetry');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statePath(): string { return path.join(getDir(), 'state.json'); }
|
||||||
|
function idPath(): string { return path.join(getDir(), '.telemetry-id'); }
|
||||||
|
|
||||||
|
function envOverride(): ConsentState | null {
|
||||||
|
const v = (process.env.BULWARK_TELEMETRY ?? '').toLowerCase();
|
||||||
|
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return 'off';
|
||||||
|
if (process.env.BULWARK_TELEMETRY_DISABLED) {
|
||||||
|
const d = process.env.BULWARK_TELEMETRY_DISABLED.toLowerCase();
|
||||||
|
if (d === '1' || d === 'true' || d === 'yes') return 'off';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDir(): Promise<void> {
|
||||||
|
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInstanceId(): Promise<string> {
|
||||||
|
await ensureDir();
|
||||||
|
try {
|
||||||
|
const id = (await readFile(idPath(), 'utf8')).trim();
|
||||||
|
if (/^[0-9a-f-]{36}$/i.test(id)) return id;
|
||||||
|
} catch { /* generate fresh */ }
|
||||||
|
const fresh = randomUUID();
|
||||||
|
const tmp = idPath() + '.tmp';
|
||||||
|
await writeFile(tmp, fresh, 'utf8');
|
||||||
|
await rename(tmp, idPath());
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default consent is 'on' - telemetry is anonymous and enabled by default.
|
||||||
|
// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing
|
||||||
|
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
|
||||||
|
const DEFAULTS: TelemetryStateFile = {
|
||||||
|
consent: 'on',
|
||||||
|
endpoint: DEFAULT_ENDPOINT,
|
||||||
|
consentedAt: null,
|
||||||
|
lastSentAt: null,
|
||||||
|
nextScheduledAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadState(): Promise<TelemetryStateFile> {
|
||||||
|
await ensureDir();
|
||||||
|
try {
|
||||||
|
const raw = await readFile(statePath(), 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<TelemetryStateFile>;
|
||||||
|
return { ...DEFAULTS, ...parsed };
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.warn('telemetry: state read failed', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// First-ever load on a fresh install: persist the default-on state with
|
||||||
|
// an autoEnabledAt stamp so the admin UI can show "telemetry was
|
||||||
|
// auto-enabled at <time>; disable here" without re-arming on restart.
|
||||||
|
const fresh: TelemetryStateFile = {
|
||||||
|
...DEFAULTS,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveState(fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveState(state: TelemetryStateFile): Promise<void> {
|
||||||
|
await ensureDir();
|
||||||
|
const tmp = statePath() + '.tmp';
|
||||||
|
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
||||||
|
await rename(tmp, statePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective consent: env var wins over file. UI changes are blocked
|
||||||
|
// when env override is active so the user knows where it's coming from.
|
||||||
|
export async function effectiveConsent(): Promise<{
|
||||||
|
consent: ConsentState;
|
||||||
|
source: 'env' | 'file';
|
||||||
|
state: TelemetryStateFile;
|
||||||
|
}> {
|
||||||
|
const envState = envOverride();
|
||||||
|
const state = await loadState();
|
||||||
|
if (envState) return { consent: envState, source: 'env', state };
|
||||||
|
return { consent: state.consent, source: 'file', state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endpointEnabled(endpoint: string | undefined): boolean {
|
||||||
|
return !!endpoint && endpoint.trim().length > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Schema v1 of the anonymous heartbeat. Documented at
|
||||||
|
// https://bulwarkmail.org/docs/legal/privacy/telemetry
|
||||||
|
|
||||||
|
export type ConsentState = 'pending' | 'on' | 'off';
|
||||||
|
|
||||||
|
export type Platform = 'docker' | 'bare' | 'k8s' | 'unknown';
|
||||||
|
export type OsFamily = 'linux' | 'darwin' | 'windows' | 'unknown';
|
||||||
|
export type CountBucket = '0' | '1' | '2-5' | '6-10' | '11-50' | '51-200' | '201+';
|
||||||
|
|
||||||
|
export interface TelemetryFeatures {
|
||||||
|
calendar: boolean;
|
||||||
|
contacts: boolean;
|
||||||
|
files: boolean;
|
||||||
|
extensions: boolean;
|
||||||
|
oauth_enabled: boolean;
|
||||||
|
smime_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryPayload {
|
||||||
|
schema: '1';
|
||||||
|
instance_id: string;
|
||||||
|
ts: string;
|
||||||
|
version: string;
|
||||||
|
build: string | null;
|
||||||
|
platform: Platform;
|
||||||
|
node_version: string;
|
||||||
|
os_family: OsFamily;
|
||||||
|
stalwart_version: string | null;
|
||||||
|
features: TelemetryFeatures;
|
||||||
|
counts: {
|
||||||
|
accounts: CountBucket;
|
||||||
|
accounts_active_7d: CountBucket;
|
||||||
|
extensions_installed: number;
|
||||||
|
themes_installed: number;
|
||||||
|
};
|
||||||
|
uptime_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryStateFile {
|
||||||
|
consent: ConsentState;
|
||||||
|
endpoint: string;
|
||||||
|
consentedAt: string | null;
|
||||||
|
lastSentAt: string | null;
|
||||||
|
nextScheduledAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ENDPOINT = 'https://telemetry.bulwarkmail.org/v1/heartbeat';
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
// Advanced Theme API v2 - compiles structured manifest fields (tokens,
|
||||||
|
// radii, typography, density, extends) into a single CSS string that the
|
||||||
|
// existing `injectThemeCSS` pipeline can apply unchanged.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ThemeDensity,
|
||||||
|
ThemeManifest,
|
||||||
|
ThemeRadii,
|
||||||
|
ThemeTokenSet,
|
||||||
|
ThemeTypography,
|
||||||
|
} from './plugin-types';
|
||||||
|
import { getLuminance, parseColor } from './color-transform';
|
||||||
|
|
||||||
|
export interface CompiledTheme {
|
||||||
|
css: string;
|
||||||
|
warnings: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard tokens whose `*-foreground` counterpart can be auto-derived from
|
||||||
|
* contrast when `derive: true` and only the base colour is supplied.
|
||||||
|
*/
|
||||||
|
const DERIVE_PAIRS: Array<[base: string, fg: string]> = [
|
||||||
|
['primary', 'primary-foreground'],
|
||||||
|
['secondary', 'secondary-foreground'],
|
||||||
|
['muted', 'muted-foreground'],
|
||||||
|
['accent', 'accent-foreground'],
|
||||||
|
['destructive', 'destructive-foreground'],
|
||||||
|
['popover', 'popover-foreground'],
|
||||||
|
['card', 'card-foreground'],
|
||||||
|
['sidebar', 'sidebar-foreground'],
|
||||||
|
['success', 'success-foreground'],
|
||||||
|
['warning', 'warning-foreground'],
|
||||||
|
['info', 'info-foreground'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Pick a foreground colour (white or near-black) by background luminance. */
|
||||||
|
function pickForeground(bg: string): string {
|
||||||
|
const rgb = parseColor(bg);
|
||||||
|
if (!rgb) return '#ffffff';
|
||||||
|
return getLuminance(rgb.r, rgb.g, rgb.b) >= 0.55 ? '#0f172a' : '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a manifest token key to a fully-qualified CSS custom property:
|
||||||
|
* "primary" → "--color-primary"
|
||||||
|
* "color-primary" → "--color-primary"
|
||||||
|
* "--color-primary" → "--color-primary"
|
||||||
|
* "font-sans" → "--font-sans"
|
||||||
|
*/
|
||||||
|
const PREFIXED_NAMESPACES = ['color-', 'font-', 'radius-', 'density-'];
|
||||||
|
function tokenName(key: string): string {
|
||||||
|
if (key.startsWith('--')) return key;
|
||||||
|
if (PREFIXED_NAMESPACES.some((ns) => key.startsWith(ns))) return `--${key}`;
|
||||||
|
return `--color-${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitTokens(
|
||||||
|
tokens: Record<string, string>,
|
||||||
|
derive: boolean,
|
||||||
|
): { lines: string[]; warnings: string[] } {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const expanded: Record<string, string> = { ...tokens };
|
||||||
|
|
||||||
|
if (derive) {
|
||||||
|
for (const [base, fg] of DERIVE_PAIRS) {
|
||||||
|
if (expanded[base] && !expanded[fg]) {
|
||||||
|
expanded[fg] = pickForeground(expanded[base]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Common alias: --color-foreground used as page text colour.
|
||||||
|
if (expanded.background && !expanded.foreground) {
|
||||||
|
expanded.foreground = pickForeground(expanded.background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [rawKey, value] of Object.entries(expanded)) {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) continue;
|
||||||
|
if (!isSafeTokenKey(rawKey)) {
|
||||||
|
warnings.push(`Token "${rawKey}" dropped - invalid key (only [a-z0-9-] allowed)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isSafeTokenValue(value)) {
|
||||||
|
warnings.push(`Token "${rawKey}" dropped - value contains unsafe characters`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
|
||||||
|
}
|
||||||
|
return { lines, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_KEY_PATTERN = /^(--)?[a-z][a-z0-9-]*$/;
|
||||||
|
function isSafeTokenKey(key: string): boolean {
|
||||||
|
return SAFE_KEY_PATTERN.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token values are emitted verbatim into CSS, so they must not contain
|
||||||
|
* anything that could break out of the declaration (`{`, `}`, `;`,
|
||||||
|
* `<`/`>`) or pull in remote/scripted content.
|
||||||
|
*/
|
||||||
|
function isSafeTokenValue(value: string): boolean {
|
||||||
|
if (/[{}<>]/.test(value)) return false;
|
||||||
|
if (value.includes(';')) return false;
|
||||||
|
if (/url\s*\(\s*['"]?(https?|data|javascript):/i.test(value)) return false;
|
||||||
|
if (/expression\s*\(/i.test(value)) return false;
|
||||||
|
if (/-moz-binding/i.test(value)) return false;
|
||||||
|
if (/javascript\s*:/i.test(value)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitRadii(radii: ThemeRadii): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(radii)) {
|
||||||
|
if (typeof v === 'string' && isSafeTokenValue(v)) {
|
||||||
|
out.push(` --radius-${k}: ${v.trim()};`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitTypography(typography: ThemeTypography): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (typography.fontSans && isSafeTokenValue(typography.fontSans)) {
|
||||||
|
out.push(` --font-sans: ${typography.fontSans.trim()};`);
|
||||||
|
}
|
||||||
|
if (typography.fontMono && isSafeTokenValue(typography.fontMono)) {
|
||||||
|
out.push(` --font-mono: ${typography.fontMono.trim()};`);
|
||||||
|
}
|
||||||
|
if (typography.fontDisplay && isSafeTokenValue(typography.fontDisplay)) {
|
||||||
|
out.push(` --font-display: ${typography.fontDisplay.trim()};`);
|
||||||
|
}
|
||||||
|
if (typography.baseFontSize && isSafeTokenValue(typography.baseFontSize)) {
|
||||||
|
out.push(` --font-size-base: ${typography.baseFontSize.trim()};`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DENSITY_VARS: Record<ThemeDensity, Record<string, string>> = {
|
||||||
|
compact: {
|
||||||
|
'--density-row-height': '28px',
|
||||||
|
'--density-control-height': '28px',
|
||||||
|
'--density-spacing-1': '2px',
|
||||||
|
'--density-spacing-2': '4px',
|
||||||
|
'--density-spacing-3': '6px',
|
||||||
|
},
|
||||||
|
normal: {
|
||||||
|
'--density-row-height': '36px',
|
||||||
|
'--density-control-height': '32px',
|
||||||
|
'--density-spacing-1': '4px',
|
||||||
|
'--density-spacing-2': '8px',
|
||||||
|
'--density-spacing-3': '12px',
|
||||||
|
},
|
||||||
|
touch: {
|
||||||
|
'--density-row-height': '44px',
|
||||||
|
'--density-control-height': '40px',
|
||||||
|
'--density-spacing-1': '6px',
|
||||||
|
'--density-spacing-2': '12px',
|
||||||
|
'--density-spacing-3': '18px',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function emitDensity(density: ThemeDensity): string[] {
|
||||||
|
return Object.entries(DENSITY_VARS[density]).map(([k, v]) => ` ${k}: ${v};`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompileOptions {
|
||||||
|
/**
|
||||||
|
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
|
||||||
|
* Implementations should return null for unknown ids; circular refs are
|
||||||
|
* the caller's problem (we don't recurse - just one level of inheritance).
|
||||||
|
*/
|
||||||
|
resolveExtends?: (id: string) => string | null;
|
||||||
|
/**
|
||||||
|
* Optional hand-written CSS appended after compiled tokens. Use this for
|
||||||
|
* the rare overrides the structured API can't express (extra `@font-face`,
|
||||||
|
* `@keyframes`, `@media (prefers-contrast)` blocks, etc.).
|
||||||
|
*/
|
||||||
|
userCSS?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile an advanced theme manifest into a single safe CSS string.
|
||||||
|
*
|
||||||
|
* Output layout:
|
||||||
|
* 1. parent (extends) CSS, if any
|
||||||
|
* 2. `:root { common + light + radii + typography + density }`
|
||||||
|
* 3. `.dark { common + dark }` (only when the theme declares a dark variant)
|
||||||
|
* 4. user-supplied `theme.css` content (sanitized upstream)
|
||||||
|
*
|
||||||
|
* The compiler never emits selectors other than `:root` and `.dark`, so the
|
||||||
|
* existing CSS sanitizer/selector validator continues to apply.
|
||||||
|
*/
|
||||||
|
export function compileAdvancedTheme(
|
||||||
|
manifest: ThemeManifest,
|
||||||
|
opts: CompileOptions = {},
|
||||||
|
): CompiledTheme {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!isAdvancedManifest(manifest)) {
|
||||||
|
return { css: '', warnings, errors: ['Manifest does not declare any advanced theme fields'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: ThemeTokenSet = manifest.tokens ?? {};
|
||||||
|
const derive = manifest.derive === true;
|
||||||
|
const wantsDark = manifest.variants.includes('dark');
|
||||||
|
const wantsLight = manifest.variants.includes('light');
|
||||||
|
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
// 1. extends - prepend parent CSS verbatim
|
||||||
|
if (manifest.extends && opts.resolveExtends) {
|
||||||
|
const parentCSS = opts.resolveExtends(manifest.extends);
|
||||||
|
if (parentCSS == null) {
|
||||||
|
warnings.push(`extends: parent theme "${manifest.extends}" not found - skipping`);
|
||||||
|
} else {
|
||||||
|
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
|
||||||
|
}
|
||||||
|
} else if (manifest.extends) {
|
||||||
|
warnings.push(`extends: no resolver provided - "${manifest.extends}" ignored`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. :root block (light + common + structural)
|
||||||
|
const rootLines: string[] = [];
|
||||||
|
|
||||||
|
if (tokens.common) {
|
||||||
|
const { lines, warnings: w } = emitTokens(tokens.common, derive);
|
||||||
|
rootLines.push(...lines);
|
||||||
|
warnings.push(...w);
|
||||||
|
}
|
||||||
|
if (wantsLight && tokens.light) {
|
||||||
|
const { lines, warnings: w } = emitTokens(tokens.light, derive);
|
||||||
|
rootLines.push(...lines);
|
||||||
|
warnings.push(...w);
|
||||||
|
}
|
||||||
|
if (manifest.radii) rootLines.push(...emitRadii(manifest.radii));
|
||||||
|
if (manifest.typography) rootLines.push(...emitTypography(manifest.typography));
|
||||||
|
if (manifest.density) rootLines.push(...emitDensity(manifest.density));
|
||||||
|
|
||||||
|
if (rootLines.length > 0) {
|
||||||
|
sections.push(`:root {\n${rootLines.join('\n')}\n}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. .dark block
|
||||||
|
if (wantsDark) {
|
||||||
|
const darkLines: string[] = [];
|
||||||
|
if (tokens.common) {
|
||||||
|
const { lines, warnings: w } = emitTokens(tokens.common, derive);
|
||||||
|
darkLines.push(...lines);
|
||||||
|
warnings.push(...w);
|
||||||
|
}
|
||||||
|
if (tokens.dark) {
|
||||||
|
const { lines, warnings: w } = emitTokens(tokens.dark, derive);
|
||||||
|
darkLines.push(...lines);
|
||||||
|
warnings.push(...w);
|
||||||
|
}
|
||||||
|
if (darkLines.length > 0) {
|
||||||
|
sections.push(`.dark {\n${darkLines.join('\n')}\n}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. hand-written overrides
|
||||||
|
if (opts.userCSS && opts.userCSS.trim()) {
|
||||||
|
sections.push(`/* user overrides */\n${opts.userCSS.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
errors.push('Compiled theme is empty - no tokens, radii, typography, or density supplied');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
css: sections.join('\n\n'),
|
||||||
|
warnings,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if a manifest opts into Theme API v2 by setting `apiVersion: 2` or by
|
||||||
|
* declaring any of the structured fields.
|
||||||
|
*/
|
||||||
|
export function isAdvancedManifest(manifest: ThemeManifest): boolean {
|
||||||
|
return (
|
||||||
|
manifest.apiVersion === 2 ||
|
||||||
|
!!manifest.tokens ||
|
||||||
|
!!manifest.extends ||
|
||||||
|
!!manifest.derive ||
|
||||||
|
!!manifest.density ||
|
||||||
|
!!manifest.radii ||
|
||||||
|
!!manifest.typography
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
|
import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
|
||||||
|
|
||||||
const THEME_STYLE_ID = 'active-theme';
|
const THEME_STYLE_ID = 'active-theme';
|
||||||
|
const THEME_SKIN_STYLE_ID = 'active-theme-skin';
|
||||||
|
const THEME_SKIN_BODY_ATTR = 'data-theme-skin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize theme CSS: strip dangerous patterns like @import, external url(),
|
* Sanitize theme CSS: strip dangerous patterns like @import, external url(),
|
||||||
@@ -88,6 +90,67 @@ export function removeThemeCSS(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject a theme's *skin* CSS - component-level overrides shipped by Theme
|
||||||
|
* API v2 themes via `skin.css`. Lives in a separate `<style>` tag so it can
|
||||||
|
* be removed cleanly without touching the colour-token block, and is placed
|
||||||
|
* AFTER the colour block so component rules win specificity.
|
||||||
|
*
|
||||||
|
* Also sets `body[data-theme-skin="<themeId>"]` so authors can scope their
|
||||||
|
* own `:not(...)` overrides if they want belt-and-braces specificity.
|
||||||
|
*/
|
||||||
|
export function injectThemeSkinCSS(css: string, themeId: string): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
let styleEl = document.getElementById(THEME_SKIN_STYLE_ID) as HTMLStyleElement | null;
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style');
|
||||||
|
styleEl.id = THEME_SKIN_STYLE_ID;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = css;
|
||||||
|
|
||||||
|
if (document.body) {
|
||||||
|
document.body.setAttribute(THEME_SKIN_BODY_ATTR, themeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeThemeSkinCSS(): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
const styleEl = document.getElementById(THEME_SKIN_STYLE_ID);
|
||||||
|
if (styleEl) styleEl.remove();
|
||||||
|
if (document.body) document.body.removeAttribute(THEME_SKIN_BODY_ATTR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a theme *skin* - looser than `sanitizeThemeCSS` because skins
|
||||||
|
* intentionally target real component selectors (toolbars, lists, buttons),
|
||||||
|
* not just `:root`/`.dark`. The same script-injection / external-resource
|
||||||
|
* prohibitions still apply.
|
||||||
|
*/
|
||||||
|
export function sanitizeSkinCSS(css: string): { css: string; warnings: string[] } {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let cleaned = css;
|
||||||
|
|
||||||
|
for (const pattern of DISALLOWED_CSS_PATTERNS) {
|
||||||
|
if (pattern.test(cleaned)) {
|
||||||
|
warnings.push(`Skin: removed disallowed pattern: ${pattern.source}`);
|
||||||
|
cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `@import` is already covered by DISALLOWED_CSS_PATTERNS, but skins also
|
||||||
|
// get an explicit no-`@charset`/`@namespace` policy so they can't change
|
||||||
|
// how the host stylesheet parses subsequent rules.
|
||||||
|
cleaned = cleaned.replace(/@(charset|namespace)\b[^;]*;?/gi, () => {
|
||||||
|
warnings.push('Skin: removed @charset/@namespace directive');
|
||||||
|
return '/* [removed] */';
|
||||||
|
});
|
||||||
|
|
||||||
|
return { css: cleaned, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a theme CSS string is valid and safe.
|
* Check if a theme CSS string is valid and safe.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2136,7 +2136,11 @@
|
|||||||
"tomorrow_header": "Tomorrow",
|
"tomorrow_header": "Tomorrow",
|
||||||
"export_ics": "Export as .ics",
|
"export_ics": "Export as .ics",
|
||||||
"copy_title": "Copy title",
|
"copy_title": "Copy title",
|
||||||
"copy_link": "Copy meeting link"
|
"copy_link": "Copy meeting link",
|
||||||
|
"new_event": "New event",
|
||||||
|
"new_all_day_event": "New all-day event",
|
||||||
|
"new_task": "New task",
|
||||||
|
"go_to_today": "Go to today"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"add_note": "Add a note...",
|
"add_note": "Add a note...",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.2",
|
"version": "1.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.2",
|
"version": "1.5.3",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.2",
|
"version": "1.5.3",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
+92
-4
@@ -2,11 +2,18 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { InstalledTheme, ThemeVariant } from '@/lib/plugin-types';
|
import type { InstalledTheme, ThemeVariant } from '@/lib/plugin-types';
|
||||||
import { pluginStorage } from '@/lib/plugin-storage';
|
import { pluginStorage } from '@/lib/plugin-storage';
|
||||||
import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS } from '@/lib/theme-loader';
|
import {
|
||||||
|
injectThemeCSS,
|
||||||
|
removeThemeCSS,
|
||||||
|
sanitizeThemeCSS,
|
||||||
|
injectThemeSkinCSS,
|
||||||
|
removeThemeSkinCSS,
|
||||||
|
} from '@/lib/theme-loader';
|
||||||
import { extractTheme } from '@/lib/plugin-validator';
|
import { extractTheme } from '@/lib/plugin-validator';
|
||||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { themeHooks } from '@/lib/plugin-hooks';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
@@ -173,9 +180,22 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
|
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { manifest, css, preview } = result;
|
const { manifest, css, skin, preview } = result;
|
||||||
const { installedThemes } = get();
|
const { installedThemes } = get();
|
||||||
|
|
||||||
|
// Carry advanced (Theme API v2) fields from the manifest through
|
||||||
|
// to the InstalledTheme so the activate/sync paths can re-compile
|
||||||
|
// or re-apply tokens later if needed.
|
||||||
|
const advancedFields = {
|
||||||
|
apiVersion: manifest.apiVersion,
|
||||||
|
extends: manifest.extends,
|
||||||
|
tokens: manifest.tokens,
|
||||||
|
derive: manifest.derive,
|
||||||
|
density: manifest.density,
|
||||||
|
radii: manifest.radii,
|
||||||
|
typography: manifest.typography,
|
||||||
|
};
|
||||||
|
|
||||||
// Check for duplicate
|
// Check for duplicate
|
||||||
if (installedThemes.some(t => t.id === manifest.id)) {
|
if (installedThemes.some(t => t.id === manifest.id)) {
|
||||||
// Update existing
|
// Update existing
|
||||||
@@ -188,12 +208,19 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
description: manifest.description || '',
|
description: manifest.description || '',
|
||||||
preview: preview || undefined,
|
preview: preview || undefined,
|
||||||
css: sanitized.css,
|
css: sanitized.css,
|
||||||
|
skin: skin ?? undefined,
|
||||||
variants: manifest.variants,
|
variants: manifest.variants,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
builtIn: false,
|
builtIn: false,
|
||||||
|
...advancedFields,
|
||||||
};
|
};
|
||||||
|
|
||||||
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
||||||
|
if (skin) {
|
||||||
|
await pluginStorage.saveThemeSkin(manifest.id, skin);
|
||||||
|
} else {
|
||||||
|
await pluginStorage.deleteThemeSkin(manifest.id);
|
||||||
|
}
|
||||||
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
@@ -215,12 +242,15 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
description: manifest.description || '',
|
description: manifest.description || '',
|
||||||
preview: preview || undefined,
|
preview: preview || undefined,
|
||||||
css: sanitized.css,
|
css: sanitized.css,
|
||||||
|
skin: skin ?? undefined,
|
||||||
variants: manifest.variants,
|
variants: manifest.variants,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
builtIn: false,
|
builtIn: false,
|
||||||
|
...advancedFields,
|
||||||
};
|
};
|
||||||
|
|
||||||
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
||||||
|
if (skin) await pluginStorage.saveThemeSkin(manifest.id, skin);
|
||||||
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
||||||
|
|
||||||
set({ installedThemes: [...installedThemes, theme] });
|
set({ installedThemes: [...installedThemes, theme] });
|
||||||
@@ -237,11 +267,13 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
// Deactivate if active
|
// Deactivate if active
|
||||||
if (activeThemeId === id) {
|
if (activeThemeId === id) {
|
||||||
removeThemeCSS();
|
removeThemeCSS();
|
||||||
|
removeThemeSkinCSS();
|
||||||
set({ activeThemeId: null });
|
set({ activeThemeId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up storage
|
// Clean up storage
|
||||||
pluginStorage.deleteThemeCSS(id);
|
pluginStorage.deleteThemeCSS(id);
|
||||||
|
pluginStorage.deleteThemeSkin(id);
|
||||||
pluginStorage.deletePreview(id);
|
pluginStorage.deletePreview(id);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
@@ -264,6 +296,7 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
|
|
||||||
if (id === null) {
|
if (id === null) {
|
||||||
removeThemeCSS();
|
removeThemeCSS();
|
||||||
|
removeThemeSkinCSS();
|
||||||
set({ activeThemeId: null });
|
set({ activeThemeId: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -407,10 +440,11 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
activeThemeId: state.activeThemeId,
|
activeThemeId: state.activeThemeId,
|
||||||
// Store theme metadata but NOT full CSS (that goes in IndexedDB)
|
// Store theme metadata but NOT full CSS / skin (those go in IndexedDB)
|
||||||
installedThemes: state.installedThemes.map(t => ({
|
installedThemes: state.installedThemes.map(t => ({
|
||||||
...t,
|
...t,
|
||||||
css: t.builtIn ? t.css : '', // only keep CSS for built-in themes
|
css: t.builtIn ? t.css : '', // only keep CSS for built-in themes
|
||||||
|
skin: undefined, // skins also in IndexedDB
|
||||||
preview: undefined, // previews also in IndexedDB
|
preview: undefined, // previews also in IndexedDB
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
@@ -434,14 +468,68 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Apply a custom theme's CSS, filtering to the appropriate variant */
|
/**
|
||||||
|
* Apply a custom theme's CSS, filtering to the appropriate variant.
|
||||||
|
*
|
||||||
|
* Fires the `themeHooks.onThemeBeforeApply` transform hook so plugins can
|
||||||
|
* post-process the CSS (e.g. inject extra `@font-face` rules or override
|
||||||
|
* specific tokens). The hook is fire-and-forget - we inject the original
|
||||||
|
* CSS synchronously first to avoid a flash, then re-inject the transformed
|
||||||
|
* version once handlers settle.
|
||||||
|
*
|
||||||
|
* If the theme also has a `skin` (Theme API v2 component-level overrides),
|
||||||
|
* it's injected into a separate `<style>` tag after the colour block so
|
||||||
|
* skin rules win specificity. The skin is hydrated lazily from IndexedDB
|
||||||
|
* on first activation.
|
||||||
|
*/
|
||||||
function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'dark'): void {
|
function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'dark'): void {
|
||||||
// If theme only supports one variant and current mode doesn't match, skip
|
// If theme only supports one variant and current mode doesn't match, skip
|
||||||
if (!theme.variants.includes(resolvedTheme as ThemeVariant)) {
|
if (!theme.variants.includes(resolvedTheme as ThemeVariant)) {
|
||||||
removeThemeCSS();
|
removeThemeCSS();
|
||||||
|
removeThemeSkinCSS();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
injectThemeCSS(theme.css);
|
injectThemeCSS(theme.css);
|
||||||
|
|
||||||
|
// Apply skin if the theme ships one. Hydrate from IndexedDB if the cached
|
||||||
|
// copy was stripped from localStorage on persist.
|
||||||
|
if (theme.skin) {
|
||||||
|
injectThemeSkinCSS(theme.skin, theme.id);
|
||||||
|
} else if (theme.apiVersion === 2) {
|
||||||
|
pluginStorage.getThemeSkin(theme.id).then((skin) => {
|
||||||
|
// Bail out if the user switched themes mid-flight.
|
||||||
|
if (useThemeStore.getState().activeThemeId !== theme.id) return;
|
||||||
|
if (skin) {
|
||||||
|
injectThemeSkinCSS(skin, theme.id);
|
||||||
|
useThemeStore.setState((state) => ({
|
||||||
|
installedThemes: state.installedThemes.map((it) =>
|
||||||
|
it.id === theme.id ? { ...it, skin } : it,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
removeThemeSkinCSS();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
removeThemeSkinCSS();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run plugin transforms asynchronously and re-inject if any handler
|
||||||
|
// modified the CSS. No handlers → no extra work.
|
||||||
|
if (themeHooks.onThemeBeforeApply.size === 0) return;
|
||||||
|
const themeId = theme.id;
|
||||||
|
themeHooks.onThemeBeforeApply
|
||||||
|
.transform(theme.css, { themeId, variant: resolvedTheme })
|
||||||
|
.then((transformed) => {
|
||||||
|
// Bail out if the user switched themes while we were awaiting handlers.
|
||||||
|
if (useThemeStore.getState().activeThemeId !== themeId) return;
|
||||||
|
if (transformed && transformed !== theme.css) {
|
||||||
|
injectThemeCSS(transformed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Hook failures are tracked by the hook bus; nothing to do here.
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Server Theme Sync Helpers ───────────────────────────────
|
// ─── Server Theme Sync Helpers ───────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user