feat: show company calendars set by the admin

This commit is contained in:
Linus Rath
2026-11-01 05:41:34 +01:00
parent 68567e08ca
commit 3bd8e83d60
53 changed files with 1592 additions and 90 deletions
@@ -0,0 +1,59 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// Company calendar addresses can hold a token: users get the feeds without
// them (the browser fetches a feed by id), the admin pages with them.
const who = vi.hoisted(() => ({
identity: null as null | { admin: true } | { admin: false; addresses: string[] },
}));
vi.mock('@/lib/auth/instance-details', () => ({
canSeeInstanceDetails: async () => true,
signedInIdentity: async () => who.identity,
}));
vi.mock('@/lib/auth/bearer-identity', () => ({ bearerIdentity: async () => null }));
const FEED = { id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=secret', color: '#2563eb', refreshMinutes: 60 };
const TEAM_FEED = { id: 'team', name: 'Team', url: 'https://intranet.example.org/team.ics?key=k', color: '#16a34a', refreshMinutes: 120 };
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
getPolicy: () => ({
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
organisation: { calendars: { feeds: [FEED], freeBusyGroup: '' } },
scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { organisation: { calendars: { feeds: [TEAM_FEED] } } } }],
}),
getPolicyWithVersion() { return { policy: this.getPolicy(), version: 'v1' }; },
},
}));
vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } }));
vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(), getClientIP: () => '0.0.0.0' }));
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() }));
import { GET } from '@/app/api/admin/policy/route';
const get = async () => (await GET(new NextRequest('https://mail.example/api/admin/policy'))).json();
beforeEach(() => {
who.identity = null;
});
describe('GET /api/admin/policy company calendar addresses', () => {
it('gives a signed-in user every feed without its address, in Everyone and in the scopes', async () => {
who.identity = { admin: false, addresses: ['alice@example.org'] };
const body = await get();
expect(body.organisation.calendars.feeds).toEqual([{ ...FEED, url: '', urlHeld: true }]);
expect(body.scopes[0].policy.organisation.calendars.feeds).toEqual([{ ...TEAM_FEED, url: '', urlHeld: true }]);
expect(JSON.stringify(body)).not.toContain('token=secret');
expect(JSON.stringify(body)).not.toContain('key=k');
});
it('gives the admin pages the addresses', async () => {
who.identity = { admin: true };
const body = await get();
expect(body.organisation.calendars.feeds[0].url).toBe(FEED.url);
expect(body.scopes[0].policy.organisation.calendars.feeds[0].url).toBe(TEAM_FEED.url);
});
});
+8 -2
View File
@@ -9,6 +9,7 @@ import { bearerIdentity } from '@/lib/auth/bearer-identity';
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
import { scopesVisibleTo } from '@/lib/admin/policy-scopes';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
import { stripFeedUrls } from '@/lib/admin/company-feeds';
/** The policy version as an HTTP entity tag. */
function policyETag(version: string): string {
@@ -83,8 +84,13 @@ export async function GET(request: NextRequest) {
}
}
/** The policy for a signed-in user who is not the admin, with the addresses they sign in with. */
function userPolicyResponse(policy: SettingsPolicy, addresses: string[]): NextResponse {
/**
* The policy for a signed-in user who is not the admin, with the addresses
* they sign in with. Company calendar addresses stay here: the browser
* fetches a feed by id through /api/fetch-ical (lib/admin/company-feeds.ts).
*/
function userPolicyResponse(raw: SettingsPolicy, addresses: string[]): NextResponse {
const policy = stripFeedUrls(raw);
const scopes = scopesVisibleTo(policy.scopes ?? [], addresses);
// Which domains the scopes were picked for, so a browser that signs in
// to an account of another domain knows to fetch again.
+129 -60
View File
@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart/credentials';
import { DisallowedUrlError, fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
import { readBodyCapped } from '@/lib/security/capped-body';
import { configManager } from '@/lib/admin/config-manager';
import { feedForSubject, feedHostname } from '@/lib/admin/company-feeds';
import { companyFeedCache } from '@/lib/admin/company-feed-cache';
import { serverPolicySubject } from '@/lib/admin/server-policy-subject';
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024; // 25MB
// Base budget for a default-sized feed; scaled up with the configured cap so a
@@ -48,43 +52,25 @@ function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: strin
return { cleanUrl: parsed.toString(), authHeader };
}
type FetchOutcome =
| { kind: 'ok'; buffer: ArrayBuffer; etag: string | null; lastModified: string | null }
| { kind: 'not-modified' }
| { kind: 'error'; response: NextResponse };
/**
* POST /api/fetch-ical
*
* Server-side proxy for iCalendar subscription URLs (the browser cannot fetch
* them directly because of CORS). Only logged-in users may use it, and every
* hop - including redirect targets - goes through `fetchPublicUrl`, which
* validates the resolved address inside the socket lookup so a rebinding DNS
* server cannot steer the connection at an internal host
* (GHSA-24w9-8r42-8jwm).
* Fetch one feed. Every hop, redirect targets included, goes through
* `fetchPublicUrl`, which validates the resolved address inside the socket
* lookup so a rebinding DNS server cannot steer the connection at an
* internal host (GHSA-24w9-8r42-8jwm). `allowPrivateHost` lifts that for the
* one hostname of a company calendar the admin entered (an intranet host).
*/
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
let body: { url?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { url } = body;
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
async function fetchIcal(
url: string,
options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null } = {},
): Promise<FetchOutcome> {
const fail = (error: string, status: number): FetchOutcome => ({ kind: 'error', response: NextResponse.json({ error }, { status }) });
const extracted = extractBasicAuth(url);
if (!extracted) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
if (!extracted) return fail('Invalid or disallowed URL', 400);
const { cleanUrl, authHeader } = extracted;
@@ -106,26 +92,28 @@ export async function POST(request: NextRequest) {
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
headers['Authorization'] = authHeader;
}
if (options.etag) headers['If-None-Match'] = options.etag;
if (options.lastModified) headers['If-Modified-Since'] = options.lastModified;
try {
response = await fetchPublicUrl(currentUrl, {
signal: controller.signal,
headers,
});
const init = { signal: controller.signal, headers };
response = options.allowPrivateHost
? await fetchPublicUrl(currentUrl, init, { allowPrivateHost: options.allowPrivateHost })
: await fetchPublicUrl(currentUrl, init);
} catch (error) {
if (error instanceof DisallowedUrlError) {
return NextResponse.json(
{ error: i === 0 ? 'Invalid or disallowed URL' : 'Redirect to disallowed URL' },
{ status: 400 },
);
return fail(i === 0 ? 'Invalid or disallowed URL' : 'Redirect to disallowed URL', 400);
}
throw error;
}
if (response.status === 304 && (options.etag || options.lastModified)) {
return { kind: 'not-modified' };
}
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
return fail('Redirect without Location header', 502);
}
currentUrl = new URL(location, currentUrl).toString();
continue;
@@ -134,15 +122,12 @@ export async function POST(request: NextRequest) {
}
if (!response || !response.ok) {
return NextResponse.json(
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
{ status: 502 }
);
return fail(`Remote server returned ${response?.status ?? 'unknown'}`, 502);
}
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > maxResponseSize) {
return tooLarge(maxResponseSize);
return { kind: 'error', response: tooLarge(maxResponseSize) };
}
// Read up to the cap and no further: a chunked body with no
@@ -151,22 +136,106 @@ export async function POST(request: NextRequest) {
// memory with it.
const buffer = await readBodyCapped(response, maxResponseSize, controller);
if (!buffer) {
return tooLarge(maxResponseSize);
return { kind: 'error', response: tooLarge(maxResponseSize) };
}
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'text/calendar',
'Content-Length': buffer.byteLength.toString(),
},
});
return { kind: 'ok', buffer, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') };
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
return fail('Request timed out', 504);
}
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
return fail('Failed to fetch calendar', 502);
} finally {
clearTimeout(timeout);
}
}
function icalResponse(buffer: ArrayBuffer): NextResponse {
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'text/calendar',
'Content-Length': buffer.byteLength.toString(),
},
});
}
/**
* A company calendar by id (organisation.calendars.feeds). The address comes
* from the caller's own scoped policy, with their groups read from their
* JMAP session here, so the browser never sees it and can't ask for the
* feed of a scope it isn't in. A feed is kept per address for its refresh
* interval, so many users cost one fetch, and asked again with
* If-None-Match / If-Modified-Since. A feed that can't be fetched keeps
* answering with what it last had.
*/
async function fetchCompanyFeed(creds: StalwartCredentials, feedId: string): Promise<NextResponse> {
await configManager.ensureLoaded();
const subject = await serverPolicySubject(creds);
const feed = feedForSubject(configManager.getPolicy(), subject, feedId);
if (!feed) {
return NextResponse.json({ error: 'Unknown calendar' }, { status: 404 });
}
const cached = companyFeedCache.get(feed.url);
if (cached && Date.now() - cached.fetchedAt < feed.refreshMinutes * 60_000) {
return icalResponse(cached.body);
}
const outcome = await fetchIcal(feed.url, {
allowPrivateHost: feedHostname(feed) ?? undefined,
etag: cached?.etag,
lastModified: cached?.lastModified,
});
if (outcome.kind === 'ok') {
companyFeedCache.set(feed.url, { body: outcome.buffer, etag: outcome.etag, lastModified: outcome.lastModified, fetchedAt: Date.now() });
return icalResponse(outcome.buffer);
}
if (cached) {
if (outcome.kind === 'not-modified') companyFeedCache.set(feed.url, { ...cached, fetchedAt: Date.now() });
return icalResponse(cached.body);
}
return outcome.kind === 'error'
? outcome.response
: NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
}
/**
* POST /api/fetch-ical
*
* Server-side proxy for iCalendar subscription URLs (the browser cannot fetch
* them directly because of CORS), `{ url }`, and for company calendars by
* id, `{ feedId }`. Only logged-in users may use it.
*/
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
let body: { url?: string; feedId?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (body && body.feedId !== undefined) {
if (typeof body.feedId !== 'string' || !body.feedId) {
return NextResponse.json({ error: 'Unknown calendar' }, { status: 404 });
}
return fetchCompanyFeed(creds, body.feedId);
}
const { url } = body ?? {};
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
const outcome = await fetchIcal(url);
if (outcome.kind === 'ok') return icalResponse(outcome.buffer);
if (outcome.kind === 'error') return outcome.response;
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
}
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
// Organisation > Calendars > Company calendars: the feeds list
// (organisation.calendars.feeds), per scope, with the notes on Lite and the
// upload limit.
let stored: Record<string, unknown> = {};
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
stored = JSON.parse(String(init.body));
return new Response('{"ok":true}');
}
return new Response(JSON.stringify(stored));
}),
withBasePath: (p: string) => p,
getPathPrefix: () => '',
}));
import { CompanyCalendarsSection } from '../sections/company-calendars-section';
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
import { sanitizeOrganisationPolicy } from '@/lib/admin/organisation-policy';
const POLICY = {
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics', color: '#16a34a', refreshMinutes: 60 }], freeBusyGroup: '' } },
scopes: [{ id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: {} }],
};
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
function Harness() {
const draft = useOrgPolicyDraft('test');
latest = draft;
if (draft.loading) return null;
return <CompanyCalendarsSection draft={draft} />;
}
beforeEach(() => {
stored = JSON.parse(JSON.stringify(POLICY));
latest = null;
useOrgScopeStore.setState({ scopeId: EVERYONE });
});
describe('Company calendars section', () => {
it('adds a feed with its name, address and colour', async () => {
render(<Harness />);
await waitFor(() => expect(screen.getAllByTestId('org-feed-item')).toHaveLength(1));
fireEvent.click(screen.getByTestId('org-feed-add'));
const names = screen.getAllByTestId('org-feed-name');
fireEvent.change(names[1], { target: { value: 'Office closures' } });
expect(screen.getByText(/Enter the address of the ICS feed/)).toBeInTheDocument();
fireEvent.change(screen.getAllByTestId('org-feed-url')[1], { target: { value: 'webcal://intranet.example.org/closed.ics' } });
await act(async () => { await latest!.save(); });
const saved = sanitizeOrganisationPolicy(stored.organisation).calendars.feeds;
expect(saved.map((f) => [f.name, f.url, f.refreshMinutes])).toEqual([
['Holidays', 'https://cal.example.org/h.ics', 60],
['Office closures', 'https://intranet.example.org/closed.ics', 60],
]);
});
it('adds a scope\'s feeds to that scope, showing Everyone\'s as inherited', async () => {
render(<Harness />);
await waitFor(() => expect(screen.getAllByTestId('org-feed-item')).toHaveLength(1));
act(() => latest!.setScopeId('team'));
expect(screen.getByTestId('org-feed-inherited')).toHaveTextContent('Holidays');
fireEvent.click(screen.getByTestId('org-feed-add'));
fireEvent.change(screen.getByTestId('org-feed-name'), { target: { value: 'Team' } });
fireEvent.change(screen.getByTestId('org-feed-url'), { target: { value: 'https://cal.example.org/team.ics' } });
const scopeFeeds = (latest!.policy?.scopes?.[0].policy.organisation as { calendars: { feeds: { name: string }[] } }).calendars.feeds;
expect(scopeFeeds.map((f) => f.name)).toEqual(['Team']);
expect(latest!.policy?.organisation?.calendars.feeds.map((f) => f.name)).toEqual(['Holidays']);
});
it('says what Lite needs and what a refresh costs', async () => {
render(<Harness />);
const notes = await screen.findByTestId('org-feed-notes');
expect(notes).toHaveTextContent('Access-Control-Allow-Origin');
expect(notes).toHaveTextContent('policy.json is a public file');
expect(notes).toHaveTextContent('1000 files or 50 MB an hour');
expect(notes).toHaveTextContent('intranet');
});
});
@@ -1,13 +1,184 @@
'use client';
import { useId } from 'react';
import { Plus, Trash2 } from '@/components/icons';
import { SettingsSection } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { MIN_FEED_REFRESH_MINUTES, type CompanyFeed } from '@/lib/admin/organisation-policy';
import { generateUUID } from '@/lib/utils';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
const inputClass =
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring';
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/company-calendars';
/** Same cap as the sanitiser (organisation-policy.ts). */
const MAX_FEEDS = 50;
const MAX_REFRESH = 7 * 24 * 60;
/** The address as it will be saved, or null when it can't be one. */
function feedUrlProblem(value: string): string | null {
const v = value.trim().replace(/^webcals?:\/\//i, 'https://');
if (!v) return 'Enter the address of the ICS feed.';
try {
const url = new URL(v);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Use an https:// (or webcal://) address.';
return null;
} catch {
return 'This isn\'t a web address.';
}
}
function FeedEditor({ feed, onChange, onRemove }: { feed: CompanyFeed; onChange: (feed: CompanyFeed) => void; onRemove: () => void }) {
const id = useId();
const set = (patch: Partial<CompanyFeed>) => onChange({ ...feed, ...patch });
const urlProblem = feedUrlProblem(feed.url);
return (
<li className="space-y-3 rounded-md border border-border p-3" data-testid="org-feed-item">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto]">
<div>
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
<input id={`${id}-name`} data-testid="org-feed-name" className={inputClass} value={feed.name} maxLength={100} onChange={(e) => set({ name: e.target.value })} />
</div>
<div>
<label htmlFor={`${id}-color`} className={labelClass}>Colour</label>
<input id={`${id}-color`} type="color" className="h-9 w-16 cursor-pointer rounded-md border border-input bg-background" value={feed.color || '#2563eb'} onChange={(e) => set({ color: e.target.value })} />
</div>
</div>
<div>
<label htmlFor={`${id}-url`} className={labelClass}>ICS address</label>
<input
id={`${id}-url`}
data-testid="org-feed-url"
type="url"
inputMode="url"
spellCheck={false}
className={inputClass}
placeholder="https://calendar.example.com/holidays.ics"
value={feed.url}
aria-invalid={!!urlProblem}
aria-describedby={urlProblem ? `${id}-url-error` : undefined}
onChange={(e) => set({ url: e.target.value })}
/>
{urlProblem && <p id={`${id}-url-error`} className="mt-1 text-xs text-red-600 dark:text-red-400">{urlProblem} A feed without one is not saved.</p>}
</div>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<label htmlFor={`${id}-refresh`} className={labelClass}>Refresh every (minutes)</label>
<input
id={`${id}-refresh`}
type="number"
inputMode="numeric"
min={MIN_FEED_REFRESH_MINUTES}
max={MAX_REFRESH}
step={15}
className={`${inputClass} w-32`}
value={feed.refreshMinutes}
onChange={(e) => {
const n = Math.round(Number(e.target.value));
if (Number.isFinite(n)) set({ refreshMinutes: Math.min(MAX_REFRESH, Math.max(MIN_FEED_REFRESH_MINUTES, n)) });
}}
/>
</div>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-9 px-2 hover:text-red-600" aria-label={`Remove ${feed.name || 'calendar'}`}>
<Trash2 className="me-1 h-4 w-4" /> Remove
</Button>
</div>
{!feed.name.trim() && <p className="text-xs text-red-600 dark:text-red-400">A calendar without a name is not saved. Give it one.</p>}
</li>
);
}
/**
* Placeholder from the enterprise-pack foundation, replaced by package B:
* ICS feeds every user in scope sees read-only.
* Organisation > Calendars > Company calendars
* (policy.json `organisation.calendars.feeds`): ICS feeds every user in the
* scope sees read-only under "Organisation". A scope's feeds add to
* Everyone's by id.
*/
export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="company-calendars" title="Company calendars" description="Not set up yet.">{null}</SettingsSection>;
const feeds = draft.orgValue('calendars', 'feeds');
const inScope = !!draft.scope;
const own: CompanyFeed[] = inScope ? (feeds.own ? feeds.value : []) : feeds.value;
const everyone = feeds.everyone;
const setOwn = (next: CompanyFeed[]) => {
if (inScope && next.length === 0) draft.clearOrgValue('calendars', 'feeds');
else draft.setOrgValue('calendars', 'feeds', next);
};
const add = () => setOwn([...own, { id: generateUUID(), name: '', url: '', color: '#2563eb', refreshMinutes: MIN_FEED_REFRESH_MINUTES }]);
const total = new Set([...everyone.map((f) => f.id), ...own.map((f) => f.id)]).size;
return (
<SettingsSection
id="company-calendars"
title="Company calendars"
description="ICS feeds such as holidays, office closures or company events. Users in the scope see them read-only under “Organisation” in Calendar and can hide them, not remove them."
>
<div className="space-y-4">
{inScope && (
<div>
<p className="mb-2 text-sm font-medium text-foreground">From Everyone</p>
{everyone.length === 0 ? (
<p className="text-sm text-muted-foreground">Everyone has no company calendars.</p>
) : (
<ul className="space-y-1.5" data-testid="org-feed-inherited">
{everyone.map((feed) => (
<li key={feed.id} className="flex items-center gap-2 rounded-md border border-dashed border-border px-3 py-2 text-sm">
<span className="h-3 w-3 shrink-0 rounded-sm" style={{ backgroundColor: feed.color }} aria-hidden="true" />
<span className="min-w-0 truncate">{feed.name}</span>
</li>
))}
</ul>
)}
<p className="mt-4 mb-1 text-sm font-medium text-foreground">For this scope</p>
<p className="mb-2 text-xs text-muted-foreground">Users in this scope get Everyone&apos;s calendars and these.</p>
</div>
)}
{own.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="org-feed-empty">
{inScope ? 'Nothing added for this scope.' : 'No company calendars yet.'}
</p>
) : (
<ul className="space-y-2" data-testid="org-feed-list">
{own.map((feed, index) => (
<FeedEditor
key={feed.id}
feed={feed}
onChange={(next) => setOwn(own.map((f, i) => (i === index ? next : f)))}
onRemove={() => setOwn(own.filter((_, i) => i !== index))}
/>
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" onClick={add} disabled={total >= MAX_FEEDS} data-testid="org-feed-add">
<Plus className="me-1 h-4 w-4" /> Add calendar
</Button>
{total >= MAX_FEEDS && <span className="text-xs text-muted-foreground">At most {MAX_FEEDS} company calendars.</span>}
</div>
<div className="space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground" data-testid="org-feed-notes">
<p>
Bulwark&apos;s server fetches the feeds, at most once per refresh interval for everyone, and keeps the addresses to
itself: users never see them. A feed may be on your intranet (a private address): only the hosts entered here are
let through. They stay reachable with &quot;No third-party traffic&quot; on and are listed on Organisation &gt; Security.
</p>
<p>
Each refresh that changed the feed uploads it once to Stalwart for every user who has Calendar open, which counts
against Stalwart&apos;s upload limit (1000 files or 50 MB an hour per user). Keep feeds small and the interval long.
</p>
<p>
Bulwark Lite has no server: the browser fetches the feed itself, so the feed host must send CORS headers
(Access-Control-Allow-Origin), a Lite build without third-party traffic must list the host in its connect-src, and
policy.json is a public file there, so don&apos;t put tokens in the addresses.{' '}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-primary underline-offset-2 hover:underline">
How to set it up
</a>
</p>
</div>
</div>
</SettingsSection>
);
}
@@ -0,0 +1,38 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
import { createCompanyCalendar } from '@/lib/company-calendars';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar } from '@/lib/jmap/types';
// Company calendars sit under "Organisation", apart from the user's own,
// and can only be shown or hidden: no menu to recolour, share or delete.
const own = {
id: 'b', name: 'Mine', color: '#2563eb', sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true,
includeInAvailability: 'all', timeZone: null, shareWith: null,
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true, mayShare: true },
} as unknown as Calendar;
const company = createCompanyCalendar({ id: 'hol', name: 'Holidays', color: '#16a34a' });
describe('CalendarSidebarPanel company calendars', () => {
it('lists them under Organisation, toggles them and offers no menu', () => {
const onToggle = vi.fn();
render(
<CalendarSidebarPanel
calendars={[own, company]}
selectedCalendarIds={['b', company.id]}
onToggleVisibility={onToggle}
client={{ getAccountId: () => 'me' } as unknown as IJMAPClient}
/>,
);
const section = screen.getByTestId('company-calendars-section');
expect(section).toHaveTextContent('section');
const item = within(section).getByText('Holidays');
fireEvent.click(item);
expect(onToggle).toHaveBeenCalledWith(company.id);
expect(within(section).queryByRole('button', { name: 'calendar_options' })).toBeNull();
// The user's own calendar is not in the section.
expect(within(section).queryByText('Mine')).toBeNull();
});
});
+39 -9
View File
@@ -81,6 +81,9 @@ import type { AnswerTarget } from "@/lib/calendar-delegate";
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import type { RevealTime } from "@/hooks/use-display-hours";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { feedIdOf, isCompanyCalendarId } from "@/lib/company-calendars";
import { expandRecurringEvents } from "@/lib/recurrence-expansion";
import { useCompanyCalendars } from "./use-company-calendars";
import { sharedCalendarColorKey, autoAssignSharedCalendarColors } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
@@ -1692,10 +1695,36 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
// Company calendars the admin set (lib/company-calendars.ts): read-only,
// shown under "Organisation"; hiding one is a synced setting of its own.
const company = useCompanyCalendars(client);
const hiddenCompanyCalendars = useSettingsStore((s) => s.hiddenCompanyCalendars);
const shownCalendarIds = useMemo(() => {
if (company.calendars.length === 0) return selectedCalendarIds;
const hidden = new Set(company.hiddenIds);
return [...selectedCalendarIds, ...company.calendars.map((c) => c.id).filter((id) => !hidden.has(id))];
}, [selectedCalendarIds, company.calendars, company.hiddenIds]);
const toggleShownCalendar = useCallback((calendarId: string) => {
if (!isCompanyCalendarId(calendarId)) {
toggleCalendarVisibility(calendarId);
return;
}
const feedId = feedIdOf(calendarId);
const hidden = Array.isArray(hiddenCompanyCalendars) ? hiddenCompanyCalendars : [];
updateSetting('hiddenCompanyCalendars', hidden.includes(feedId) ? hidden.filter((id) => id !== feedId) : [...hidden, feedId]);
}, [toggleCalendarVisibility, hiddenCompanyCalendars, updateSetting]);
const companyEvents = useMemo(() => {
if (!dateRange || company.events.length === 0) return [];
const hidden = new Set(company.hiddenIds);
return expandRecurringEvents(company.events.filter((e) => !Object.keys(e.calendarIds).some((id) => hidden.has(id))), dateRange.start, dateRange.end);
}, [company.events, company.hiddenIds, dateRange]);
const allCalendars = useMemo(() => {
if (!showBirthdayCalendar) return displayCalendars;
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const withBirthdays = showBirthdayCalendar
? [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)]
: displayCalendars;
return company.calendars.length > 0 ? [...withBirthdays, ...company.calendars] : withBirthdays;
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor, company.calendars]);
// What the user may change, by the rights of each event's calendar
// (birthdays, subscriptions and read-only shares can't be changed).
@@ -1719,11 +1748,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id));
});
const withCompany = companyEvents.length > 0 ? [...filtered, ...companyEvents] : filtered;
if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
return [...filtered, ...birthdayEvents];
return [...withCompany, ...birthdayEvents];
}
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
return withCompany;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents, companyEvents]);
// The tasks the task list shows; plugin buttons in its toolbar act on them.
const listedTaskIds = useMemo(
@@ -2000,7 +2030,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
rangeEnd={scrollWindow?.end ?? selectedDate}
events={visibleEvents}
calendars={allCalendars}
shownCalendars={allCalendars.filter((c) => selectedCalendarIds.includes(c.id))}
shownCalendars={allCalendars.filter((c) => shownCalendarIds.includes(c.id))}
gridTasks={calendarTasks}
tasks={taskStore.tasks}
taskFilter={taskStore.filter}
@@ -2147,8 +2177,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
</div>
<CalendarSidebarPanel
calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
selectedCalendarIds={shownCalendarIds}
onToggleVisibility={toggleShownCalendar}
onColorChange={client ? (calendarId, color) => {
if (calendarId === BIRTHDAY_CALENDAR_ID) {
updateSetting('birthdayCalendarColor', color);
+12 -2
View File
@@ -96,6 +96,7 @@ export function CalendarSidebarPanel({
const paint = useCvdColor();
const tSub = useTranslations("calendar.subscription");
const tMgmt = useTranslations("calendar.management");
const tCompany = useTranslations("company_calendars");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const allSubs = useCalendarStore((s) => s.icalSubscriptions);
const currentAccountId = client?.getAccountId();
@@ -152,7 +153,9 @@ export function CalendarSidebarPanel({
// view but hidden from the event calendar list here.
const eventCalendars = useMemo(() => calendars.filter(c => !c.isTasksOnly), [calendars]);
const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared), [eventCalendars]);
const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared && !c.isCompany), [eventCalendars]);
// Company calendars the admin set: under "Organisation", read-only.
const companyCalendars = useMemo(() => eventCalendars.filter(c => c.isCompany), [eventCalendars]);
// Names for the "shared with" hint. Principal ids belong to one server, so
// only calendars of the account `client` talks to are resolved.
@@ -280,7 +283,8 @@ export function CalendarSidebarPanel({
const renderCalendarItem = (cal: Calendar) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = paint(cal.color || "#3b82f6");
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
// A company calendar can only be shown or hidden: no menu.
const hasMenu = cal.isCompany ? false : isSubscriptionCalendar(cal.id) ? !!client : true;
return (
<div key={cal.id} className="relative group/cal">
@@ -607,6 +611,12 @@ export function CalendarSidebarPanel({
onCreateCalendar,
)}
{companyCalendars.length > 0 && (
<div className="mt-2" data-testid="company-calendars-section">
{renderSection('__organisation__', tCompany('section'), companyCalendars)}
</div>
)}
{sharedAccountGroups.length > 0 && (
<div className="mt-2">
{renderSectionHeader('__others__', t('other_calendars'))}
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { usePolicyStore } from "@/stores/policy-store";
import { useSettingsStore } from "@/stores/settings-store";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Calendar, CalendarEvent } from "@/lib/jmap/types";
import type { CompanyFeed } from "@/lib/admin/organisation-policy";
import { companyCalendarId, createCompanyCalendar, loadFeedEvents, toCompanyEvents } from "@/lib/company-calendars";
// Feeds decide for themselves when they are due (refreshMinutes, at least
// an hour); this only looks whether one is.
const CHECK_EVERY_MS = 15 * 60 * 1000;
const NO_FEEDS: CompanyFeed[] = [];
/**
* The company calendars of the active account's policy, and their events
* (series not expanded). Feeds the policy no longer lists go away at once;
* feeds that arrive with a late policy load then.
*/
export function useCompanyCalendars(client: IJMAPClient | null): {
calendars: Calendar[];
events: CalendarEvent[];
/** Calendar ids of the feeds the user hid. */
hiddenIds: string[];
} {
const feeds = usePolicyStore((s) => s.policy.organisation?.calendars?.feeds ?? NO_FEEDS);
const hidden = useSettingsStore((s) => s.hiddenCompanyCalendars);
const [parsed, setParsed] = useState<Record<string, CalendarEvent[]>>({});
const accountKey = client?.getAccountId?.() ?? "";
const feedsKey = JSON.stringify(feeds.map((f) => [f.id, f.url, f.refreshMinutes]));
useEffect(() => {
if (!client || feeds.length === 0) return;
let cancelled = false;
const load = () => {
for (const feed of feeds) {
void loadFeedEvents(client, accountKey, feed).then((events) => {
if (cancelled) return;
setParsed((current) => ({ ...current, [feed.id]: toCompanyEvents(feed.id, events) }));
});
}
};
load();
const timer = setInterval(load, CHECK_EVERY_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
// feedsKey stands in for the feeds array identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, accountKey, feedsKey]);
return useMemo(() => {
const calendars = feeds.map(createCompanyCalendar);
const events = feeds.flatMap((feed) => parsed[feed.id] ?? []);
const hiddenSet = new Set(Array.isArray(hidden) ? hidden : []);
const hiddenIds = feeds.filter((f) => hiddenSet.has(f.id)).map((f) => companyCalendarId(f.id));
return { calendars, events, hiddenIds };
}, [feeds, parsed, hidden]);
}
+116
View File
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const lite = vi.hoisted(() => ({ value: false }));
vi.mock('@/lib/lite', async (original) => ({
...(await original<typeof import('@/lib/lite')>()),
get IS_LITE() { return lite.value; },
}));
const apiFetch = vi.fn();
vi.mock('@/lib/browser-navigation', async (original) => ({
...(await original<typeof import('@/lib/browser-navigation')>()),
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
import {
companyCalendarId, createCompanyCalendar, isCompanyCalendarId, loadFeedEvents, resetCompanyCalendarMemory, toCompanyEvents,
} from '../company-calendars';
import { purgeSignedOutData } from '../sign-out-cleanup';
import type { CompanyFeed } from '@/lib/admin/organisation-policy';
import type { CalendarEvent } from '@/lib/jmap/types';
const FEED: CompanyFeed = { id: 'hol', name: 'Holidays', url: '', urlHeld: true, color: '#16a34a', refreshMinutes: 60 };
const ICS = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n';
const PARSED = [{ uid: '1', title: 'Closed', start: '2026-12-24T00:00:00', duration: 'P1D', showWithoutTime: true }];
function fakeClient() {
return {
getCalendarsAccountId: () => 'alice',
uploadBlob: vi.fn(async () => ({ blobId: 'blob1', size: 1, type: 'text/calendar' })),
parseCalendarEvents: vi.fn(async () => PARSED as Partial<CalendarEvent>[]),
};
}
const HOUR = 3600_000;
const T0 = Date.parse('2026-11-02T08:00:00Z');
beforeEach(() => {
resetCompanyCalendarMemory();
apiFetch.mockReset();
apiFetch.mockImplementation(async () => new Response(ICS));
lite.value = false;
// No IndexedDB in jsdom: memory only.
});
describe('loadFeedEvents', () => {
it('fetches the feed by id through the server, uploads it on the XHR path and parses it', async () => {
const client = fakeClient();
expect(await loadFeedEvents(client, 'alice', FEED, T0)).toEqual(PARSED);
expect(apiFetch).toHaveBeenCalledWith('/api/fetch-ical', expect.objectContaining({ method: 'POST', body: JSON.stringify({ feedId: 'hol' }) }));
const [, options] = client.uploadBlob.mock.calls[0] as unknown as [File, { accountId: string; onProgress?: unknown }];
expect(options.accountId).toBe('alice');
expect(typeof options.onProgress).toBe('function');
expect(client.parseCalendarEvents).toHaveBeenCalledWith('alice', 'blob1');
});
it('asks again only after the refresh interval, and uploads only a feed that changed', async () => {
const client = fakeClient();
await loadFeedEvents(client, 'alice', FEED, T0);
await loadFeedEvents(client, 'alice', FEED, T0 + 30 * 60_000);
expect(apiFetch).toHaveBeenCalledTimes(1);
await loadFeedEvents(client, 'alice', FEED, T0 + HOUR + 1);
expect(apiFetch).toHaveBeenCalledTimes(2);
expect(client.uploadBlob).toHaveBeenCalledTimes(1);
apiFetch.mockImplementation(async () => new Response(ICS.replace('UID:1', 'UID:2')));
await loadFeedEvents(client, 'alice', FEED, T0 + 2 * HOUR + 2);
expect(client.uploadBlob).toHaveBeenCalledTimes(2);
});
it('keeps the last events when the feed can\'t be fetched', async () => {
const client = fakeClient();
await loadFeedEvents(client, 'alice', FEED, T0);
apiFetch.mockImplementation(async () => new Response('down', { status: 502 }));
expect(await loadFeedEvents(client, 'alice', FEED, T0 + 2 * HOUR)).toEqual(PARSED);
expect(await loadFeedEvents(fakeClient(), 'bob', FEED, T0)).toEqual([]);
});
it('fetches the address itself in Lite, where there is no server', async () => {
lite.value = true;
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(ICS));
try {
await loadFeedEvents(fakeClient(), 'alice', { ...FEED, url: 'https://cal.example.org/h.ics', urlHeld: undefined }, T0);
expect(fetchMock).toHaveBeenCalledWith('https://cal.example.org/h.ics', expect.objectContaining({ credentials: 'omit' }));
expect(apiFetch).not.toHaveBeenCalled();
} finally {
fetchMock.mockRestore();
}
});
it('forgets every feed on a full sign-out', async () => {
const client = fakeClient();
await loadFeedEvents(client, 'alice', FEED, T0);
purgeSignedOutData();
await loadFeedEvents(client, 'alice', FEED, T0 + 60_000);
expect(apiFetch).toHaveBeenCalledTimes(2);
});
});
describe('the company calendar and its events', () => {
it('is read-only and out of free/busy', () => {
const calendar = createCompanyCalendar(FEED);
expect(calendar).toMatchObject({ id: '__company-calendar__:hol', name: 'Holidays', color: '#16a34a', isCompany: true, includeInAvailability: 'none' });
expect(Object.values(calendar.myRights).filter(Boolean)).toEqual([true, true]); // read free/busy and items only
expect(isCompanyCalendarId(calendar.id)).toBe(true);
expect(isCompanyCalendarId('b')).toBe(false);
});
it('turns parsed events into events of the calendar, with nothing to answer', () => {
const events = toCompanyEvents('hol', [
{ ...PARSED[0], participants: { a: { email: 'x@example.org' } } as never, organizerCalendarAddress: 'mailto:x@example.org' },
{ uid: 'no-start', title: 'Broken' },
]);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ calendarIds: { [companyCalendarId('hol')]: true }, participants: null, organizerCalendarAddress: null, title: 'Closed' });
});
});
+129
View File
@@ -0,0 +1,129 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Company calendars by id (POST /api/fetch-ical { feedId }): the address
// comes from the caller's own scoped policy, their groups read from their
// JMAP session on the server, the admin's host may be on the intranet, and
// a feed is fetched once per refresh interval for everyone.
vi.mock('next/server', () => {
class NextResponse {
status: number;
headers: Headers;
body: unknown;
constructor(body: unknown, init?: { status?: number; headers?: Record<string, string> }) {
this.body = body;
this.status = init?.status ?? 200;
this.headers = new Headers(init?.headers);
}
static json(data: unknown, init?: { status?: number }) {
return Object.assign(new NextResponse(data, init), { json: async () => data });
}
}
return { NextResponse, NextRequest: class {} };
});
const creds = vi.hoisted(() => ({ value: null as null | Record<string, unknown> }));
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => creds.value }));
const guardedFetch = vi.fn();
vi.mock('@/lib/security/url-guard', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/security/url-guard')>()),
fetchPublicUrl: (...args: unknown[]) => guardedFetch(...args),
}));
// The caller's JMAP session: alice is in the group ent-team and has bob's
// mailbox shared with her (a person, not a group).
const session = vi.hoisted(() => ({
accounts: {} as Record<string, { name: string; isPersonal: boolean }>,
types: {} as Record<string, string>,
}));
vi.mock('@/lib/stalwart/jmap-api', () => ({
fetchJmapSession: async () => ({
apiUrl: 'https://mail.example.org/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'alice' },
accounts: session.accounts,
}),
rebaseApiUrl: () => 'https://mail.example.org/jmap/',
postJmap: async () => new Response(JSON.stringify({
methodResponses: [['Principal/get', { list: Object.entries(session.types).map(([id, type]) => ({ id, type })) }, '0']],
})),
}));
const POLICY = {
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }] } },
scopes: [
{ id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://cal.intranet.example/team.ics', refreshMinutes: 120 }] } } } },
{ id: 'bob', label: 'Bob', match: { groups: ['ent-bob@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'bobs', name: 'Bob', url: 'https://cal.example.org/b.ics' }] } } } },
],
};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, getPolicy: () => POLICY },
}));
import { POST } from '@/app/api/fetch-ical/route';
import { companyFeedCache } from '@/lib/admin/company-feed-cache';
import { clearServerPolicySubjects } from '@/lib/admin/server-policy-subject';
type RouteResponse = { status: number; body: unknown };
const post = async (body: unknown) => (await POST({ method: 'POST', headers: new Headers(), json: async () => body } as never)) as unknown as RouteResponse;
const ics = (status = 200, headers: Record<string, string> = {}) => new Response(status === 304 ? null : 'BEGIN:VCALENDAR\nEND:VCALENDAR\n', { status, headers: { 'content-type': 'text/calendar', ...headers } });
beforeEach(() => {
creds.value = { serverUrl: 'https://mail.example.org', authHeader: 'Basic alice', username: 'ent-alice@example.org', accountName: 'ent-alice@example.org', trusted: true };
session.accounts = {
alice: { name: 'ent-alice@example.org', isPersonal: true },
team: { name: 'ent-team@example.org', isPersonal: false },
bob: { name: 'ent-bob@example.org', isPersonal: false },
};
session.types = { team: 'group', bob: 'individual' };
guardedFetch.mockReset();
companyFeedCache.clear();
clearServerPolicySubjects();
});
afterEach(() => vi.useRealTimers());
describe('POST /api/fetch-ical { feedId }', () => {
it('fetches a feed of Everyone and one of a group the session shows the user is in, from the policy on the server', async () => {
guardedFetch.mockImplementation(async () => ics());
expect((await post({ feedId: 'hol' })).status).toBe(200);
expect(guardedFetch.mock.calls[0][0]).toBe('https://cal.example.org/h.ics?token=t');
expect((await post({ feedId: 'team' })).status).toBe(200);
// The admin's own intranet host may be private, and only it.
expect(guardedFetch.mock.calls[1][0]).toBe('http://cal.intranet.example/team.ics');
expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'cal.intranet.example' });
});
it('refuses a feed of a scope the user is not in, without fetching anything', async () => {
session.accounts = { alice: { name: 'ent-alice@example.org', isPersonal: true } };
expect((await post({ feedId: 'team' })).status).toBe(404);
// A person's shared mailbox is no group, whatever it is called.
expect((await post({ feedId: 'bobs' })).status).toBe(404);
expect((await post({ feedId: 'nope' })).status).toBe(404);
expect((await post({ feedId: 42 })).status).toBe(404);
expect(guardedFetch).not.toHaveBeenCalled();
});
it('answers from what it fetched within the refresh interval, and asks again with the validators after it', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-11-02T08:00:00Z'));
guardedFetch.mockResolvedValueOnce(ics(200, { etag: '"v1"', 'last-modified': 'Mon, 02 Nov 2026 07:00:00 GMT' }));
await post({ feedId: 'hol' });
await post({ feedId: 'hol' });
expect(guardedFetch).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date('2026-11-02T09:01:00Z'));
guardedFetch.mockResolvedValueOnce(ics(304));
const res = await post({ feedId: 'hol' });
expect(res.status).toBe(200);
expect(Buffer.from(res.body as ArrayBuffer).toString()).toContain('BEGIN:VCALENDAR');
expect(guardedFetch).toHaveBeenCalledTimes(2);
expect(guardedFetch.mock.calls[1][1].headers).toMatchObject({ 'If-None-Match': '"v1"', 'If-Modified-Since': 'Mon, 02 Nov 2026 07:00:00 GMT' });
});
it('needs a signed-in user', async () => {
creds.value = null;
expect((await post({ feedId: 'hol' })).status).toBe(401);
});
});
@@ -0,0 +1,66 @@
// @vitest-environment node
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
// A company calendar may live on the intranet: fetchPublicUrl lets exactly
// the host the admin entered resolve to a private address, and nothing else
// (a redirect to another private host, another name), with the address still
// pinned at connect time.
const lookup = vi.fn();
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return {
...actual,
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
lookup: (...args: unknown[]) => lookup(...args),
};
});
describe('fetchPublicUrl with allowPrivateHost', () => {
let server: Server;
let port: number;
let hits = 0;
beforeAll(async () => {
server = createServer((_req, res) => {
hits++;
res.writeHead(200, { 'content-type': 'text/calendar' });
res.end('BEGIN:VCALENDAR\nEND:VCALENDAR\n');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
lookup.mockReset();
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
hits = 0;
});
it('reaches the admin\'s intranet host', async () => {
const { fetchPublicUrl } = await import('@/lib/security/url-guard');
const res = await fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`, {}, { allowPrivateHost: 'cal.intranet.example' });
expect(res.status).toBe(200);
expect(hits).toBe(1);
});
it('allows .internal names only for that host', async () => {
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
const res = await fetchPublicUrl(`http://calendar.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' });
expect(res.status).toBe(200);
await expect(fetchPublicUrl(`http://other.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' }))
.rejects.toBeInstanceOf(DisallowedUrlError);
});
it('keeps refusing every other host that resolves to a private address', async () => {
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
await expect(fetchPublicUrl(`http://rebind.example:${port}/cal.ics`, {}, { allowPrivateHost: 'cal.intranet.example' }))
.rejects.toBeInstanceOf(DisallowedUrlError);
await expect(fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`)).rejects.toBeInstanceOf(DisallowedUrlError);
expect(hits).toBe(0);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { allFeeds, feedForSubject, stripFeedUrls } from '../company-feeds';
import { listReachableHosts } from '../reachable-hosts';
import { sanitizeOrganisationPolicy } from '../organisation-policy';
import type { SettingsPolicy } from '../types';
const policy = {
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }], freeBusyGroup: '' } },
scopes: [
{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://intranet.example.org/team.ics', refreshMinutes: 120 }] } } } },
{ id: 'two', label: 'Org two', match: { domains: ['two.example'] }, policy: { organisation: { calendars: { feeds: [{ id: 'two', name: 'Two', url: 'https://two.example/cal.ics' }] } } } },
],
} as unknown as SettingsPolicy;
describe('company feeds on the server', () => {
it('empties every address in the policy users get, and the browser keeps the feeds', () => {
const stripped = stripFeedUrls(policy);
expect(stripped.organisation?.calendars.feeds[0]).toMatchObject({ id: 'hol', name: 'Holidays', url: '', urlHeld: true });
expect((stripped.scopes![0].policy.organisation as { calendars: { feeds: { url: string }[] } }).calendars.feeds[0].url).toBe('');
// The original is untouched (the admin pages read it).
expect(policy.organisation?.calendars.feeds[0].url).toContain('token=t');
// An emptied address survives the sanitiser the browser runs.
expect(sanitizeOrganisationPolicy(stripped.organisation).calendars.feeds.map((f) => [f.id, f.url])).toEqual([['hol', '']]);
});
it('resolves a feed id only for a subject its scope applies to', () => {
expect(feedForSubject(policy, { email: 'a@example.org', groups: ['team@example.org'] }, 'team')?.url).toBe('http://intranet.example.org/team.ics');
expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'team')).toBeNull();
expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'hol')?.name).toBe('Holidays');
expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'two')).toBeNull();
expect(feedForSubject(policy, { email: 'b@two.example', groups: [] }, 'two')?.name).toBe('Two');
});
it('lists every scope\'s feed host as reachable', () => {
expect(allFeeds(policy).map((f) => f.id)).toEqual(['hol', 'team', 'two']);
const hosts = listReachableHosts({ get: () => undefined, policy, env: {} })
.filter((h) => h.kind === 'Company calendar');
expect(hosts.map((h) => [h.host, h.setIn, h.side])).toEqual([
['cal.example.org', 'Organisation > Calendars (Holidays)', 'server'],
['intranet.example.org', 'Organisation > Calendars (Team)', 'server'],
['two.example', 'Organisation > Calendars (Two)', 'server'],
]);
});
});
@@ -85,6 +85,15 @@ describe('enterprise sections: defaults and sanitising', () => {
expect(calendars.freeBusyGroup).toBe('all-staff@example.org');
});
it('keeps a feed whose address the server held back, and drops one without an address', () => {
const { calendars } = sanitizeOrganisationPolicy({ calendars: { feeds: [
{ id: 'kept', name: 'Holidays', url: '', urlHeld: true },
{ id: 'none', name: 'No address' },
{ id: 'empty', name: 'Typed nothing yet', url: '' },
] } });
expect(calendars.feeds.map((f) => [f.id, f.url, f.urlHeld])).toEqual([['kept', '', true]]);
});
it('keeps device switches only as booleans', () => {
const { devices } = sanitizeOrganisationPolicy({ devices: { noDownloads: true, noPrint: 'yes', watermark: 1 } });
expect(devices.noDownloads).toBe(true);
+38
View File
@@ -0,0 +1,38 @@
/**
* Company calendar feeds as the server last fetched them, by address: the
* body, its validators and when it was fetched. In memory, once per process
* (not per bundle copy), and at most MAX_ENTRIES feeds; a replica that
* starts fetches again.
*/
export interface CachedFeed {
body: ArrayBuffer;
etag: string | null;
lastModified: string | null;
fetchedAt: number;
}
const MAX_ENTRIES = 200;
const KEY = Symbol.for('bulwark.companyFeedCache');
function store(): Map<string, CachedFeed> {
const g = globalThis as typeof globalThis & { [KEY]?: Map<string, CachedFeed> };
g[KEY] ??= new Map();
return g[KEY];
}
export const companyFeedCache = {
get(url: string): CachedFeed | undefined {
return store().get(url);
},
set(url: string, entry: CachedFeed): void {
const map = store();
map.delete(url);
map.set(url, entry);
// The oldest entry goes first.
while (map.size > MAX_ENTRIES) map.delete(map.keys().next().value as string);
},
clear(): void {
store().clear();
},
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Company calendars on the server (organisation.calendars.feeds).
*
* A feed address may carry a token, so full Bulwark never hands it to the
* browser: the policy users get has every feed's `url` emptied
* (stripFeedUrls), and /api/fetch-ical fetches a feed by id from the
* caller's own scoped policy (feedForSubject). Lite has no server: there the
* address is in the public policy.json, which the admin docs say.
*/
import type { SettingsPolicy } from './types';
import type { CompanyFeed } from './organisation-policy';
import { resolvePolicy, type PolicySubject } from './policy-scopes';
function withoutUrls(organisation: unknown): unknown {
if (!organisation || typeof organisation !== 'object') return organisation;
const org = organisation as Record<string, unknown>;
const calendars = org.calendars as Record<string, unknown> | undefined;
if (!calendars || !Array.isArray(calendars.feeds)) return organisation;
return {
...org,
calendars: {
...calendars,
feeds: calendars.feeds.map((feed) => (feed && typeof feed === 'object' ? { ...(feed as Record<string, unknown>), url: '', urlHeld: true } : feed)),
},
};
}
/** The policy as users get it: every company calendar's address emptied, in Everyone and in every scope. */
export function stripFeedUrls(policy: SettingsPolicy): SettingsPolicy {
return {
...policy,
...(policy.organisation ? { organisation: withoutUrls(policy.organisation) as SettingsPolicy['organisation'] } : {}),
...(policy.scopes
? {
scopes: policy.scopes.map((scope) => (scope.policy?.organisation
? { ...scope, policy: { ...scope.policy, organisation: withoutUrls(scope.policy.organisation) as typeof scope.policy.organisation } }
: scope)),
}
: {}),
};
}
/** The feed `feedId` as it applies to `subject` (their domain and group scopes), or null when it doesn't. */
export function feedForSubject(policy: SettingsPolicy, subject: PolicySubject, feedId: string): CompanyFeed | null {
const feeds = resolvePolicy(policy, subject).policy.organisation?.calendars?.feeds ?? [];
const feed = feeds.find((f) => f.id === feedId);
return feed && feed.url ? feed : null;
}
/** Every feed of every scope, for the outgoing-hosts list. */
export function allFeeds(policy: Pick<SettingsPolicy, 'organisation' | 'scopes'>): CompanyFeed[] {
const lists = [policy.organisation, ...(policy.scopes ?? []).map((scope) => scope.policy?.organisation)]
.map((org) => (org as { calendars?: { feeds?: unknown } } | undefined)?.calendars?.feeds);
return lists.flatMap((feeds) => (Array.isArray(feeds) ? feeds.filter((f): f is CompanyFeed => !!f && typeof f === 'object' && typeof (f as CompanyFeed).url === 'string') : []));
}
/** The hostnames of the admin's feeds: the only private hosts a feed fetch may reach. */
export function feedHostname(feed: Pick<CompanyFeed, 'url'>): string | null {
try {
return new URL(feed.url).hostname.toLowerCase();
} catch {
return null;
}
}
+12 -3
View File
@@ -56,7 +56,14 @@ export interface VacationTemplate {
export interface CompanyFeed {
id: string;
name: string;
/** The ICS address; '' when `urlHeld`. */
url: string;
/**
* Set in the policy full Bulwark hands to users: the server keeps the
* address back (it may hold a token) and fetches the feed by id
* (app/api/fetch-ical, lib/admin/company-feeds.ts).
*/
urlHeld?: boolean;
/** #rrggbb */
color: string;
refreshMinutes: number;
@@ -407,10 +414,12 @@ function feedUrl(value: unknown): string | null {
function sanitizeFeeds(value: unknown): CompanyFeed[] | undefined {
return byIdList(value, MAX_FEEDS, (item, id) => {
const name = line(item.name, 100);
const url = feedUrl(item.url);
if (!name || !url) return null;
// An empty address only for a feed whose address the server held back.
const held = item.urlHeld === true && item.url === '';
const url = held ? '' : feedUrl(item.url);
if (!name || url === null) return null;
const minutes = count(item.refreshMinutes, 7 * 24 * 60) ?? MIN_FEED_REFRESH_MINUTES;
return { id, name, url, color: color(item.color, '#2563eb'), refreshMinutes: Math.max(MIN_FEED_REFRESH_MINUTES, minutes) };
return { id, name, url, color: color(item.color, '#2563eb'), refreshMinutes: Math.max(MIN_FEED_REFRESH_MINUTES, minutes), ...(held ? { urlHeld: true } : {}) };
});
}
+5 -1
View File
@@ -2,6 +2,7 @@ import type { SettingsPolicy } from './types';
import { parseJmapServers } from './jmap-servers';
import { isBuiltInHost } from '@/lib/outgoing-hosts';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
import { allFeeds } from './company-feeds';
/**
* What stays reachable with "no third-party traffic" on: the hosts the
@@ -39,7 +40,7 @@ function parse(url: unknown): URL | null {
export interface ReachableInput {
get: (key: string) => unknown;
policy: Pick<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'defaultSidebarApps'> & { features?: Partial<SettingsPolicy['features']> };
policy: Pick<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'defaultSidebarApps'> & Partial<Pick<SettingsPolicy, 'organisation' | 'scopes'>> & { features?: Partial<SettingsPolicy['features']> };
env: Record<string, string | undefined>;
/** The relay the browser bundle was built with (NEXT_PUBLIC_PUSH_RELAY_URL inlined at build time). */
buildRelayUrl?: string;
@@ -88,6 +89,9 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R
'Set in the environment only: browsers use the relay this build was made with. Rebuild, or add it under User policy > Push relays.');
}
add('Extension directory', get('extensionDirectoryUrl'), 'EXTENSION_DIRECTORY_URL', 'server');
// Company calendars of Everyone and of every scope: this server fetches
// them (app/api/fetch-ical by feed id), whatever the switch says.
for (const feed of allFeeds(policy)) add('Company calendar', feed.url, `Organisation > Calendars (${feed.name})`, 'server');
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser');
add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both');
add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server');
+93
View File
@@ -0,0 +1,93 @@
/**
* Who a request is for the domain and group scopes, worked out on the
* server from the caller's own JMAP session: the account's address and the
* group accounts in its session, as policySubjectForClient
* (lib/admin/policy-subject.ts) does in the browser. Used where the server
* must apply a group scope itself (company calendars by feed id), so a
* browser can't claim a group it isn't in.
*/
import { createHash } from 'node:crypto';
import type { PolicySubject } from './policy-scopes';
import type { StalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import { UPSTREAM_JSON_MAX_BYTES, readJsonCapped } from '@/lib/security/capped-body';
const TTL_MS = 5 * 60 * 1000;
const PRINCIPALS = 'urn:ietf:params:jmap:principals';
const MAIL = 'urn:ietf:params:jmap:mail';
type Creds = Pick<StalwartCredentials, 'serverUrl' | 'authHeader' | 'accountName' | 'username' | 'trusted'>;
interface SessionAccount {
name?: string;
isPersonal?: boolean;
}
// One cache per process, not per bundle copy.
const CACHE_KEY = Symbol.for('bulwark.serverPolicySubjects');
function cache(): Map<string, { at: number; subject: Promise<PolicySubject> }> {
const g = globalThis as typeof globalThis & { [CACHE_KEY]?: Map<string, { at: number; subject: Promise<PolicySubject> }> };
g[CACHE_KEY] ??= new Map();
return g[CACHE_KEY];
}
async function resolve(creds: Creds): Promise<PolicySubject> {
const email = (creds.accountName || creds.username).trim().toLowerCase();
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader, { trusted: creds.trusted });
if (!session?.accounts) return { email, groups: [] };
const primary = session.primaryAccounts?.[MAIL] ?? '';
const shared = Object.entries(session.accounts as Record<string, SessionAccount>)
.filter(([id, account]) => id !== primary && account?.isPersonal === false && typeof account.name === 'string' && account.name.includes('@'));
if (shared.length === 0) return { email, groups: [] };
// A mailbox another person shared is non-personal too: the principal type
// tells groups apart. When the server won't say, every non-personal
// account counts, as in the browser.
let types: Record<string, string> | null = null;
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
if (apiUrl && primary) {
try {
const response = await postJmap(apiUrl, creds.authHeader, JSON.stringify({
using: ['urn:ietf:params:jmap:core', PRINCIPALS],
methodCalls: [['Principal/get', { accountId: primary, ids: shared.map(([id]) => id), properties: ['id', 'type'] }, '0']],
}), { trusted: creds.trusted });
if (response.ok) {
const body = await readJsonCapped<{ methodResponses?: [string, { list?: Array<{ id?: string; type?: string }> }, string][] }>(response, UPSTREAM_JSON_MAX_BYTES);
const [method, result] = body?.methodResponses?.[0] ?? [];
if (method === 'Principal/get') {
types = {};
for (const p of result?.list ?? []) if (p.id && p.type) types[p.id] = p.type;
}
}
} catch {
types = null;
}
}
const groups = shared
.filter(([id]) => !types || types[id] === 'group')
.map(([, account]) => account.name!.trim().toLowerCase())
.sort();
return { email, groups };
}
/** The caller's subject, kept for a few minutes per credential. Never rejects. */
export function serverPolicySubject(creds: Creds): Promise<PolicySubject> {
const key = createHash('sha256').update(`${creds.serverUrl}\n${creds.authHeader}`).digest('hex');
const entries = cache();
const now = Date.now();
const hit = entries.get(key);
if (hit && now - hit.at < TTL_MS) return hit.subject;
const email = (creds.accountName || creds.username).trim().toLowerCase();
const subject = resolve(creds).catch((): PolicySubject => ({ email, groups: [] }));
entries.set(key, { at: now, subject });
if (entries.size > 1000) {
for (const [k, v] of entries) if (now - v.at >= TTL_MS) entries.delete(k);
}
return subject;
}
/** For tests. */
export function clearServerPolicySubjects(): void {
cache().clear();
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Company calendars (organisation.calendars.feeds): ICS feeds the admin
* lists, shown to every user in scope as read-only calendars under
* "Organisation".
*
* They are virtual calendars in the browser, like the birthday calendar, not
* calendars imported into each user's account (the subscription sync in
* lib/calendar-subscription-sync.ts does that): nothing lands in anyone's
* free/busy, removing a feed removes it everywhere at once, and Lite runs the
* same code. What is reused from the subscriptions is the fetch (full
* Bulwark: /api/fetch-ical, here by feed id so the address stays on the
* server) and the parsing (CalendarEvent/parse on an uploaded blob, so there
* is no ICS parser in the bundle).
*
* The upload quota (1000 files or 50 MB an hour on Stalwart) is spared: a
* feed is fetched at most once per refresh interval (60 minutes or more),
* its text is hashed and not uploaded again while it is unchanged, and the
* parsed events are kept per account in IndexedDB, which a full sign-out
* deletes. Uploads take the XHR path, so a 429 there doesn't freeze the rest
* of the client.
*/
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import type { CompanyFeed } from '@/lib/admin/organisation-policy';
import { apiFetch } from '@/lib/browser-navigation';
import { IS_LITE } from '@/lib/lite';
import { onFullSignOut } from '@/lib/sign-out-cleanup';
import { debug } from '@/lib/debug';
export const COMPANY_CALENDAR_PREFIX = '__company-calendar__:';
export function companyCalendarId(feedId: string): string {
return `${COMPANY_CALENDAR_PREFIX}${feedId}`;
}
export function isCompanyCalendarId(id: string): boolean {
return id.startsWith(COMPANY_CALENDAR_PREFIX);
}
export function feedIdOf(calendarId: string): string {
return calendarId.slice(COMPANY_CALENDAR_PREFIX.length);
}
/** The read-only calendar a feed is shown as. */
export function createCompanyCalendar(feed: Pick<CompanyFeed, 'id' | 'name' | 'color'>): Calendar {
return {
id: companyCalendarId(feed.id),
name: feed.name,
description: null,
color: feed.color || '#2563eb',
sortOrder: 1000,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'none',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
isCompany: true,
myRights: {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayShare: false,
mayDelete: false,
},
};
}
/** The parsed events of a feed as events of its calendar: no attendees, nothing to answer. */
export function toCompanyEvents(feedId: string, parsed: Partial<CalendarEvent>[]): CalendarEvent[] {
const calendarId = companyCalendarId(feedId);
const events: CalendarEvent[] = [];
parsed.forEach((event, index) => {
if (typeof event.start !== 'string' || !event.start) return;
const rest = { ...(event as Record<string, unknown>) };
delete rest.method;
events.push({
...(rest as Partial<CalendarEvent>),
id: `company-${feedId}-${index}`,
uid: event.uid || `company-${feedId}-${index}`,
title: event.title || '',
calendarIds: { [calendarId]: true },
// A feed's ATTENDEE lines are someone else's invitation: shown as an
// event, never as one to answer.
participants: null,
replyTo: null,
organizerCalendarAddress: null,
isDraft: false,
isOrigin: false,
alerts: null,
useDefaultAlerts: false,
} as CalendarEvent);
});
return events;
}
export interface FeedCacheEntry {
/** SHA-256 of the feed text the events were parsed from. */
hash: string;
fetchedAt: number;
events: Partial<CalendarEvent>[];
}
// ── IndexedDB, per account ──────────────────────────────────────────
const DB_NAME = 'bulwark-company-calendars';
const STORE = 'feeds';
function openDb(): Promise<IDBDatabase | null> {
if (typeof indexedDB === 'undefined') return Promise.resolve(null);
return new Promise((resolve) => {
try {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE);
request.onsuccess = () => resolve(request.result);
request.onerror = () => resolve(null);
request.onblocked = () => resolve(null);
} catch {
resolve(null);
}
});
}
async function idbGet(key: string): Promise<FeedCacheEntry | null> {
const db = await openDb();
if (!db) return null;
return new Promise((resolve) => {
try {
const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
request.onsuccess = () => resolve((request.result as FeedCacheEntry | undefined) ?? null);
request.onerror = () => resolve(null);
} catch {
resolve(null);
}
}).finally(() => db.close()) as Promise<FeedCacheEntry | null>;
}
async function idbPut(key: string, value: FeedCacheEntry): Promise<void> {
const db = await openDb();
if (!db) return;
await new Promise<void>((resolve) => {
try {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
tx.onabort = () => resolve();
} catch {
resolve();
}
});
db.close();
}
const memory = new Map<string, FeedCacheEntry>();
/** Drop every cached feed: on a full sign-out the events are the organisation's. */
export function clearCompanyCalendarCache(): void {
memory.clear();
inFlight.clear();
if (typeof indexedDB === 'undefined') return;
try {
indexedDB.deleteDatabase(DB_NAME);
} catch {
/* storage unavailable */
}
}
onFullSignOut(clearCompanyCalendarCache);
async function sha256(text: string): Promise<string> {
const subtle = globalThis.crypto?.subtle;
if (subtle) {
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
}
// No WebCrypto (an http origin): a plain string hash still tells a change.
let h = 0;
for (let i = 0; i < text.length; i++) h = (Math.imul(31, h) + text.charCodeAt(i)) | 0;
return `s${text.length}-${h}`;
}
/** The feed text: by id through the server in full Bulwark, from its address in Lite. */
async function fetchFeedText(feed: CompanyFeed): Promise<string> {
const response = IS_LITE
? await fetch(feed.url, { headers: { Accept: 'text/calendar, application/ics, text/plain, */*' }, credentials: 'omit' })
: await apiFetch('/api/fetch-ical', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedId: feed.id }),
});
if (!response.ok) throw new Error(`Company calendar ${feed.id}: ${response.status}`);
return response.text();
}
type FeedClient = Pick<IJMAPClient, 'uploadBlob' | 'parseCalendarEvents' | 'getCalendarsAccountId'>;
const inFlight = new Map<string, Promise<Partial<CalendarEvent>[]>>();
/**
* The parsed events of one feed for one account: from the cache while it is
* fresh, else fetched, and parsed again only when the text changed. A feed
* that can't be fetched keeps what was cached. Never rejects.
*/
export function loadFeedEvents(client: FeedClient, accountKey: string, feed: CompanyFeed, now = Date.now()): Promise<Partial<CalendarEvent>[]> {
const key = `${accountKey}|${feed.id}`;
const running = inFlight.get(key);
if (running) return running;
const work = (async () => {
const cached = memory.get(key) ?? (await idbGet(key));
if (cached) memory.set(key, cached);
if (cached && now - cached.fetchedAt < feed.refreshMinutes * 60_000) return cached.events;
try {
const text = await fetchFeedText(feed);
const hash = await sha256(text);
let events = cached?.events ?? [];
if (!cached || cached.hash !== hash) {
const accountId = client.getCalendarsAccountId();
const file = new File([text], 'company-calendar.ics', { type: 'text/calendar' });
// The XHR path (progress callback): a 429 is this upload's alone.
const uploaded = await client.uploadBlob(file, { accountId, onProgress: () => {} });
events = await client.parseCalendarEvents(accountId, uploaded.blobId);
}
const entry: FeedCacheEntry = { hash, fetchedAt: now, events };
memory.set(key, entry);
await idbPut(key, entry);
return events;
} catch (error) {
debug.warn('calendar', 'Company calendar could not be refreshed', { feed: feed.id, error });
return cached?.events ?? [];
}
})().finally(() => inFlight.delete(key));
inFlight.set(key, work);
return work;
}
/** For tests. */
export function resetCompanyCalendarMemory(): void {
memory.clear();
inFlight.clear();
}
+3
View File
@@ -676,6 +676,9 @@ export interface Calendar {
// hidden from the event calendar UI while remaining available to the tasks
// view (#761). Undefined means "not determined" (treated as not tasks-only).
isTasksOnly?: boolean;
// A company calendar the admin set (lib/company-calendars.ts): a read-only
// calendar that exists only in the browser.
isCompany?: boolean;
}
/**
+64 -9
View File
@@ -34,7 +34,7 @@ function isBlockedHostname(hostname: string): boolean {
* hostnames / suffixes, and literal IP addresses. Returns the parsed URL when
* those checks pass, `null` otherwise. Does NOT touch DNS.
*/
function parseAllowedUrl(urlString: string): URL | null {
function parseAllowedUrl(urlString: string, privateHost?: string): URL | null {
let url: URL;
try {
url = new URL(urlString);
@@ -46,6 +46,7 @@ function parseAllowedUrl(urlString: string): URL | null {
if (url.username || url.password) return null;
const hostname = normalizeHostname(url.hostname);
if (privateHost && hostname === privateHost) return url;
if (isBlockedHostname(hostname)) return null;
if (isBlockedIpAddress(hostname)) return null;
@@ -213,9 +214,50 @@ export function envProxyFor(url: URL): URL | null {
}
}
function lookupChecked(hostname: string): Promise<string> {
/**
* guardedLookup, except that `privateHost` may resolve to a private address:
* an intranet host the admin entered themselves (company calendars). Every
* other name, redirect targets included, is still checked, and the address
* is still the one the socket connects to.
*/
function lookupAllowing(privateHost: string): typeof guardedLookup {
return (hostname, options, callback) => {
if (normalizeHostname(hostname) !== privateHost) {
guardedLookup(hostname, options, callback);
return;
}
const cb = (typeof options === 'function' ? options : callback) as LookupCallback;
const opts: LookupOptions = typeof options === 'function' ? {} : typeof options === 'number' ? { family: options } : (options ?? {});
lookup(normalizeHostname(hostname), { family: opts.family, hints: opts.hints, all: true, verbatim: true })
.then((records) => {
if (records.length === 0) {
const err: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
err.code = 'ENOTFOUND';
cb(err);
return;
}
if (opts.all) cb(null, records);
else cb(null, records[0].address, records[0].family);
})
.catch((err: NodeJS.ErrnoException) => cb(err));
};
}
const privateHostDispatchers = new Map<string, Dispatcher>();
function getPrivateHostDispatcher(privateHost: string): Dispatcher {
let dispatcher = privateHostDispatchers.get(privateHost);
if (!dispatcher) {
dispatcher = new Agent({ connect: { lookup: lookupAllowing(privateHost) as unknown as LookupFunction } });
privateHostDispatchers.set(privateHost, dispatcher);
}
return dispatcher;
}
function lookupChecked(hostname: string, privateHost?: string): Promise<string> {
const look = privateHost ? lookupAllowing(privateHost) : guardedLookup;
return new Promise((resolve, reject) => {
guardedLookup(hostname, {}, (err, address) => (err ? reject(err) : resolve(address as string)));
look(hostname, {}, (err, address) => (err ? reject(err) : resolve(address as string)));
});
}
@@ -229,8 +271,9 @@ const proxiedDispatchers = new Map<string, Dispatcher>();
* address. The request keeps its Host header, and TLS inside the tunnel uses
* the hostname for SNI and the certificate check.
*/
function getProxiedPublicDispatcher(proxy: URL): Dispatcher {
let dispatcher = proxiedDispatchers.get(proxy.href);
function getProxiedPublicDispatcher(proxy: URL, privateHost?: string): Dispatcher {
const cacheKey = privateHost ? `${proxy.href}|${privateHost}` : proxy.href;
let dispatcher = proxiedDispatchers.get(cacheKey);
if (dispatcher) return dispatcher;
const proxyClient = new Client(proxy.origin);
@@ -241,7 +284,7 @@ function getProxiedPublicDispatcher(proxy: URL): Dispatcher {
const connect: buildConnector.connector = (opts, callback) => {
const hostname = normalizeHostname(opts.hostname);
const port = Number(opts.port) || (opts.protocol === 'https:' ? 443 : 80);
lookupChecked(hostname)
lookupChecked(hostname, privateHost)
.then(async (address) => {
const authority = `${isIP(address) === 6 ? `[${address}]` : address}:${port}`;
const tunnel = await proxyClient.connect({
@@ -265,7 +308,7 @@ function getProxiedPublicDispatcher(proxy: URL): Dispatcher {
.catch((err: Error) => callback(err, null));
};
dispatcher = new Agent({ connect });
proxiedDispatchers.set(proxy.href, dispatcher);
proxiedDispatchers.set(cacheKey, dispatcher);
return dispatcher;
}
@@ -296,18 +339,30 @@ export type PublicFetchResponse = UndiciResponse;
export async function fetchPublicUrl(
url: string,
init: Omit<UndiciRequestInit, 'dispatcher' | 'redirect'> = {},
options: {
/**
* One hostname that may be a private (intranet) host: only for an
* address the admin entered (company calendars). It must equal the
* URL's hostname to make any difference.
*/
allowPrivateHost?: string;
} = {},
): Promise<PublicFetchResponse> {
const parsed = parseAllowedUrl(url);
const privateHost = options.allowPrivateHost ? normalizeHostname(options.allowPrivateHost) : undefined;
const parsed = parseAllowedUrl(url, privateHost);
if (!parsed) {
throw new DisallowedUrlError(url);
}
const proxy = envProxyFor(parsed);
const exempt = privateHost && normalizeHostname(parsed.hostname) === privateHost ? privateHost : undefined;
try {
return await undiciFetch(url, {
...init,
redirect: 'manual',
dispatcher: proxy ? getProxiedPublicDispatcher(proxy) : getPublicDispatcher(),
dispatcher: proxy
? getProxiedPublicDispatcher(proxy, exempt)
: exempt ? getPrivateHostDispatcher(exempt) : getPublicDispatcher(),
});
} catch (err) {
const blocked = findBlockedCause(err);
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "ألغى {name} \"{title}\"",
"untitled": "حدث بدون عنوان"
},
"company_calendars": {
"section": "المؤسسة"
},
"vacation_calendar": {
"label": "حجز تقويمي",
"description": "يضيف حدثًا مشغولًا لمدة غيابك، حتى لا يحجز أحد اجتماعات معك خلالها.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} ha cancel·lat \"{title}\"",
"untitled": "Esdeveniment sense títol"
},
"company_calendars": {
"section": "Organització"
},
"vacation_calendar": {
"label": "Bloca el meu calendari",
"description": "Afegeix un esdeveniment ocupat mentre sou fora, perquè ningú us convoqui a reunions aleshores.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} zrušil(a) „{title}“",
"untitled": "Událost bez názvu"
},
"company_calendars": {
"section": "Organizace"
},
"vacation_calendar": {
"label": "Zablokovat můj kalendář",
"description": "Přidá obsazenou událost na dobu vaší nepřítomnosti, aby vám tehdy nikdo neplánoval schůzky.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} har aflyst \"{title}\"",
"untitled": "Unavngivet begivenhed"
},
"company_calendars": {
"section": "Organisation"
},
"vacation_calendar": {
"label": "Bloker min kalender",
"description": "Tilføjer en optaget begivenhed, mens du er væk, så ingen booker møder med dig imens.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} hat „{title}“ abgesagt",
"untitled": "Unbenannter Termin"
},
"company_calendars": {
"section": "Organisation"
},
"vacation_calendar": {
"label": "Meinen Kalender blockieren",
"description": "Trägt für Ihre Abwesenheit einen belegten Termin ein, damit Ihnen niemand Besprechungen in dieser Zeit einträgt.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} cancelled \"{title}\"",
"untitled": "Untitled event"
},
"company_calendars": {
"section": "Organisation"
},
"vacation_calendar": {
"label": "Block my calendar",
"description": "Adds a busy event for the time you’re away, so nobody books meetings with you then.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} ha cancelado \"{title}\"",
"untitled": "Evento sin título"
},
"company_calendars": {
"section": "Organización"
},
"vacation_calendar": {
"label": "Bloquear mi calendario",
"description": "Añade un evento ocupado mientras estás fuera, para que nadie te convoque a reuniones entonces.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} tühistas sündmuse \"{title}\"",
"untitled": "Nimetu sündmus"
},
"company_calendars": {
"section": "Organisatsioon"
},
"vacation_calendar": {
"label": "Blokeeri minu kalender",
"description": "Lisab äraoleku ajaks hõivatud sündmuse, et keegi sel ajal sulle koosolekuid ei paneks.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} «{title}» را لغو کرد",
"untitled": "رویداد بدون عنوان"
},
"company_calendars": {
"section": "سازمان"
},
"vacation_calendar": {
"label": "مسدود کردن تقویم من",
"description": "برای مدت غیبت شما یک رویداد مشغول اضافه می‌کند تا کسی در آن زمان جلسه نگذارد.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} a annulé « {title} »",
"untitled": "Événement sans titre"
},
"company_calendars": {
"section": "Organisation"
},
"vacation_calendar": {
"label": "Bloquer mon calendrier",
"description": "Ajoute un événement occupé pendant votre absence, pour que personne ne vous invite à une réunion à ce moment-là.",
+3
View File
@@ -5255,6 +5255,9 @@
"cancelled": "{name} ביטל/ה את \"{title}\"",
"untitled": "אירוע ללא כותרת"
},
"company_calendars": {
"section": "ארגון"
},
"vacation_calendar": {
"label": "חסום את היומן שלי",
"description": "מוסיף אירוע תפוס לזמן ההיעדרות, כדי שאף אחד לא יקבע איתך פגישות אז.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} lemondta a(z) „{title}” eseményt",
"untitled": "Névtelen esemény"
},
"company_calendars": {
"section": "Szervezet"
},
"vacation_calendar": {
"label": "Naptáram foglalása",
"description": "Foglalt eseményt tesz a távollét idejére, hogy addig senki ne szervezzen Önnel megbeszélést.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} ha annullato \"{title}\"",
"untitled": "Evento senza titolo"
},
"company_calendars": {
"section": "Organizzazione"
},
"vacation_calendar": {
"label": "Blocca il mio calendario",
"description": "Aggiunge un evento occupato per il periodo di assenza, così nessuno ti fissa riunioni in quei giorni.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} さんが「{title}」をキャンセルしました",
"untitled": "無題の予定"
},
"company_calendars": {
"section": "組織"
},
"vacation_calendar": {
"label": "カレンダーをブロック",
"description": "不在の期間に予定ありの予定を追加し、その間に会議を入れられないようにします。",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다",
"untitled": "제목 없는 일정"
},
"company_calendars": {
"section": "조직"
},
"vacation_calendar": {
"label": "내 캘린더 차단",
"description": "부재 기간에 바쁨 일정을 추가해 그동안 회의가 잡히지 않게 합니다.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} atcēla “{title}”",
"untitled": "Notikums bez nosaukuma"
},
"company_calendars": {
"section": "Organizācija"
},
"vacation_calendar": {
"label": "Bloķēt manu kalendāru",
"description": "Pievieno aizņemtu notikumu prombūtnes laikam, lai tad neviens neplānotu ar jums sapulces.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} \"{title}\"-г цуцаллаа",
"untitled": "Нэргүй арга хэмжээ"
},
"company_calendars": {
"section": "Байгууллага"
},
"vacation_calendar": {
"label": "Миний хуанлийг хаах",
"description": "Таныг байхгүй үед завгүй арга хэмжээ нэмж, тэр үед хэн ч уулзалт товлохгүй болгоно.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} har avlyst «{title}»",
"untitled": "Hendelse uten tittel"
},
"company_calendars": {
"section": "Organisasjon"
},
"vacation_calendar": {
"label": "Blokker kalenderen min",
"description": "Legger inn en opptatt-hendelse mens du er borte, så ingen booker møter med deg da.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} heeft \"{title}\" geannuleerd",
"untitled": "Afspraak zonder titel"
},
"company_calendars": {
"section": "Organisatie"
},
"vacation_calendar": {
"label": "Mijn agenda blokkeren",
"description": "Zet een bezette afspraak in je agenda voor de tijd dat je weg bent, zodat niemand dan vergaderingen met je plant.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} odwołał(a) „{title}”",
"untitled": "Wydarzenie bez tytułu"
},
"company_calendars": {
"section": "Organizacja"
},
"vacation_calendar": {
"label": "Zablokuj mój kalendarz",
"description": "Dodaje zajęte wydarzenie na czas Twojej nieobecności, żeby nikt nie umawiał wtedy z Tobą spotkań.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} cancelou \"{title}\"",
"untitled": "Evento sem título"
},
"company_calendars": {
"section": "Organização"
},
"vacation_calendar": {
"label": "Bloquear o meu calendário",
"description": "Adiciona um evento ocupado durante a sua ausência, para que ninguém marque reuniões consigo nessa altura.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} a anulat „{title}”",
"untitled": "Eveniment fără titlu"
},
"company_calendars": {
"section": "Organizație"
},
"vacation_calendar": {
"label": "Blochează-mi calendarul",
"description": "Adaugă un eveniment ocupat pe durata absenței, ca nimeni să nu vă programeze ședințe atunci.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} отменил(а) «{title}»",
"untitled": "Событие без названия"
},
"company_calendars": {
"section": "Организация"
},
"vacation_calendar": {
"label": "Занять мой календарь",
"description": "Добавляет событие «занят» на время отсутствия, чтобы вам не назначали встречи.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} zrušil(a) „{title}“",
"untitled": "Udalosť bez názvu"
},
"company_calendars": {
"section": "Organizácia"
},
"vacation_calendar": {
"label": "Zablokovať môj kalendár",
"description": "Pridá obsadenú udalosť na čas vašej neprítomnosti, aby vám vtedy nikto neplánoval stretnutia.",
+3
View File
@@ -2185,6 +2185,9 @@
"cancelled": "{name} ยกเลิกกิจกรรม “{title}”",
"untitled": "กิจกรรมที่ไม่มีชื่อ"
},
"company_calendars": {
"section": "องค์กร"
},
"vacation_calendar": {
"label": "บล็อกปฏิทินของฉัน",
"description": "เพิ่มกิจกรรมไม่ว่างในช่วงที่คุณไม่อยู่ เพื่อไม่ให้ใครนัดประชุมกับคุณในเวลานั้น",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} \"{title}\" etkinliğini iptal etti",
"untitled": "Başlıksız etkinlik"
},
"company_calendars": {
"section": "Kuruluş"
},
"vacation_calendar": {
"label": "Takvimimi kapat",
"description": "Uzakta olduğunuz süre için meşgul bir etkinlik ekler, böylece o sırada kimse sizinle toplantı ayarlamaz.",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} скасував(ла) «{title}»",
"untitled": "Подія без назви"
},
"company_calendars": {
"section": "Організація"
},
"vacation_calendar": {
"label": "Заблокувати мій календар",
"description": "Додає подію «зайнято» на час вашої відсутності, щоб вам тоді не призначали зустрічей.",
+3
View File
@@ -4857,6 +4857,9 @@
"cancelled": "{name} đã hủy \"{title}\"",
"untitled": "Sự kiện không có tiêu đề"
},
"company_calendars": {
"section": "Tổ chức"
},
"vacation_calendar": {
"label": "Chặn lịch của tôi",
"description": "Thêm một sự kiện bận trong thời gian bạn vắng mặt để không ai đặt cuộc họp với bạn lúc đó.",
+3
View File
@@ -4845,6 +4845,9 @@
"cancelled": "{name} 取消了「{title}」",
"untitled": "未命名活動"
},
"company_calendars": {
"section": "組織"
},
"vacation_calendar": {
"label": "佔用我的行事曆",
"description": "在你外出期間新增一個忙碌活動,這樣別人不會在這段時間約你開會。",
+3
View File
@@ -5283,6 +5283,9 @@
"cancelled": "{name} 取消了“{title}”",
"untitled": "无标题活动"
},
"company_calendars": {
"section": "组织"
},
"vacation_calendar": {
"label": "占用我的日历",
"description": "在你外出期间添加一个忙碌活动,这样别人不会在这段时间约你开会。",
+4
View File
@@ -627,6 +627,8 @@ interface SettingsState extends AccessibilitySettings {
vacationBlockCalendar: boolean;
/** The times of each account's out-of-office event as Bulwark last wrote them, by account id. */
vacationCalendarBlocks: Record<string, { start: string; end: string }>;
/** Company calendars (organisation.calendars.feeds) the user hid, by feed id. */
hiddenCompanyCalendars: string[];
// Calendar Tasks
enableCalendarTasks: boolean;
@@ -952,6 +954,7 @@ const DEFAULT_SETTINGS = {
calendarWorkingDays: [1, 2, 3, 4, 5] as number[],
vacationBlockCalendar: true,
vacationCalendarBlocks: {} as Record<string, { start: string; end: string }>,
hiddenCompanyCalendars: [] as string[],
// Calendar Tasks
enableCalendarTasks: false,
@@ -1394,6 +1397,7 @@ export const useSettingsStore = create<SettingsState>()(
calendarWorkingDays: state.calendarWorkingDays,
vacationBlockCalendar: state.vacationBlockCalendar,
vacationCalendarBlocks: state.vacationCalendarBlocks,
hiddenCompanyCalendars: state.hiddenCompanyCalendars,
toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher,
showRailAccountList: state.showRailAccountList,