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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deploy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
}

(frontend_csp) {
header Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.{$BEEBUZZ_DOMAIN}; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
header Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' https://api.{$BEEBUZZ_DOMAIN}; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
}

(security_headers) {
Expand Down
6 changes: 6 additions & 0 deletions docs/MESSAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ On the Hive side, the service worker:
- decrypts the fetched blob locally
- persists the resolved notification to IndexedDB before showing it

Hive attachment viewing is intentionally narrow:

- previews inline for `image/jpeg`, `image/png`, `image/webp`, `image/gif`, and best-effort `video/mp4`
- keeps all other attachment types download-only
- renders previews and downloads from the locally fetched and decrypted blob rather than exposing the attachment token URL to browser media elements

## BeeBuzz CLI

The CLI in `cmd/beebuzz` uses E2E delivery. See [E2E_ENCRYPTION.md](E2E_ENCRYPTION.md) for the full sending flow and plaintext payload shape.
Expand Down
176 changes: 145 additions & 31 deletions web/apps/hive/src/lib/components/notification_card.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {
Image,
FileDown,
Video,
ChevronUp,
EllipsisVertical,
Check,
Expand All @@ -11,9 +12,10 @@
} from '@lucide/svelte';
import type { Notification, NotificationPriority } from '@beebuzz/shared/types';
import { notificationsStore, formatRelativeTime } from '$lib/stores/notifications.svelte';
import { fetchAndCacheAttachment, isImageMime } from '$lib/utils/attachmentCache';
import { fetchAndCacheAttachment, isImageMime, isVideoMime } from '$lib/utils/attachmentCache';
import type { CachedAttachment } from '$lib/utils/attachmentCache';
import { parseHttpsLinkSegments } from '$lib/utils/linkify';
import { tick } from 'svelte';

const PRIORITY_HIGH: NotificationPriority = 'high';

Expand Down Expand Up @@ -48,9 +50,11 @@
});

let cachedAttachment = $state<CachedAttachment | null>(null);
let showImageModal = $state(false);
let showAttachmentViewer = $state(false);
let attachmentLoading = $state(false);
let imageDialog = $state<HTMLDialogElement | undefined>(undefined);
let attachmentDialog = $state<HTMLDialogElement | undefined>(undefined);
let viewerObjectUrl = $state<string | null>(null);
let viewerPreviewFailed = $state(false);

// Why: DaisyUI's focus-based dropdown breaks in Safari — focus leaves the
// trigger before onclick fires on menu items, swallowing the event.
Expand Down Expand Up @@ -78,11 +82,15 @@
});

$effect(() => {
if (!imageDialog) return;
if (showImageModal && cachedAttachment) {
imageDialog.showModal();
} else {
imageDialog.close();
if (!attachmentDialog) return;
if (showAttachmentViewer && cachedAttachment) {
if (!attachmentDialog.open) {
attachmentDialog.showModal();
}
return;
}
if (attachmentDialog.open) {
attachmentDialog.close();
}
});

Expand All @@ -106,25 +114,62 @@
menuOpen = false;
}

/** Trigger browser download for non-image attachments. */
function triggerDownload(dataUrl: string, filename: string) {
function attachmentFilename(): string {
return notification.attachment?.filename || 'attachment.bin';
}

/** Trigger browser download for an already-loaded attachment. */
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = dataUrl;
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
}

function isPreviewableAttachment(mime: string): boolean {
return isImageMime(mime) || isVideoMime(mime);
}

async function closeAttachmentViewer() {
const oldUrl = viewerObjectUrl;
viewerObjectUrl = null;
viewerPreviewFailed = false;
showAttachmentViewer = false;
await tick();
if (oldUrl) {
URL.revokeObjectURL(oldUrl);
}
}

function handleViewerClose() {
void closeAttachmentViewer();
}

function handleVideoError() {
viewerPreviewFailed = true;
}

/** Show image modal or trigger download depending on MIME type. */
function handleLoaded() {
/** Open the viewer for the already-loaded attachment. */
function openAttachmentViewer() {
if (!cachedAttachment) return;
if (isImageMime(cachedAttachment.mimeType)) {
showImageModal = true;
} else {
const filename = notification.attachment?.filename || 'attachment.bin';
triggerDownload(cachedAttachment.dataUrl, filename);
viewerPreviewFailed = false;
if (viewerObjectUrl) {
URL.revokeObjectURL(viewerObjectUrl);
viewerObjectUrl = null;
}
if (isPreviewableAttachment(cachedAttachment.mimeType)) {
viewerObjectUrl = URL.createObjectURL(cachedAttachment.blob);
}
showAttachmentViewer = true;
}

function handleDownloadAttachment() {
if (!cachedAttachment) return;
triggerDownload(cachedAttachment.blob, attachmentFilename());
}

/** Returns a cached attachment assembled from inline base64 notification data. */
Expand All @@ -135,8 +180,14 @@
}

const mimeType = notification.attachment?.mime || 'application/octet-stream';
const binary = atob(inlineData);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}

return {
dataUrl: `data:${mimeType};base64,${inlineData}`,
blob: new Blob([bytes], { type: mimeType }),
mimeType,
timestamp: Date.now()
};
Expand All @@ -149,9 +200,28 @@
if (notification.attachment?.mime && isImageMime(notification.attachment.mime)) {
return 'Image attachment';
}
if (notification.attachment?.mime && isVideoMime(notification.attachment.mime)) {
return 'Video attachment';
}
return 'File attachment';
});

const showingImagePreview = $derived.by(
() =>
!!cachedAttachment &&
!!viewerObjectUrl &&
!viewerPreviewFailed &&
isImageMime(cachedAttachment.mimeType)
);

const showingVideoPreview = $derived.by(
() =>
!!cachedAttachment &&
!!viewerObjectUrl &&
!viewerPreviewFailed &&
isVideoMime(cachedAttachment.mimeType)
);

const relativeTime = $derived(formatRelativeTime(notification.sentAt));
const absoluteTime = $derived(
notification.sentAt.toLocaleString('en-US', {
Expand Down Expand Up @@ -183,14 +253,14 @@
async function loadAttachment() {
if (selectionMode) return;
if (cachedAttachment) {
handleLoaded();
openAttachmentViewer();
return;
}

const inlineAttachment = buildInlineAttachment();
if (inlineAttachment) {
cachedAttachment = inlineAttachment;
handleLoaded();
openAttachmentViewer();
return;
}

Expand All @@ -200,7 +270,7 @@
attachmentLoading = true;
try {
cachedAttachment = await fetchAndCacheAttachment(url, notification.attachment.mime, true);
handleLoaded();
openAttachmentViewer();
} catch (err) {
console.error('[NotificationCard] Failed to load attachment:', err);
} finally {
Expand Down Expand Up @@ -360,6 +430,8 @@
>
{#if notification.attachment?.mime && isImageMime(notification.attachment.mime)}
<Image size={16} />
{:else if notification.attachment?.mime && isVideoMime(notification.attachment.mime)}
<Video size={16} />
{:else}
<FileDown size={16} />
{/if}
Expand All @@ -372,23 +444,65 @@
{/if}
</div>

<!-- Image modal — rendered outside the card to avoid inheriting opacity-60 on read messages -->
<dialog bind:this={imageDialog} class="modal">
<div class="modal-box max-w-4xl w-11/12 flex flex-col items-center">
<dialog
bind:this={attachmentDialog}
class="modal"
onclose={handleViewerClose}
oncancel={handleViewerClose}
>
<div class="modal-box max-w-4xl w-11/12 flex flex-col gap-4">
<form method="dialog">
<button
type="submit"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
aria-label="Close image viewer"
aria-label="Close attachment viewer"
>
</button>
</form>
<img
src={cachedAttachment?.dataUrl}
alt="{notification.title} attachment"
class="rounded-lg max-w-full max-h-96"
/>

<div class="pr-8">
<h4 class="text-sm font-semibold text-base-content">{attachmentLabel}</h4>
<p class="mt-1 text-xs text-base-content/70">
{cachedAttachment?.mimeType || 'Unknown type'}
</p>
</div>

<div class="flex justify-end">
<button type="button" class="btn btn-sm gap-2" onclick={handleDownloadAttachment}>
<FileDown size={16} />
Download
</button>
</div>

{#if showingImagePreview}
<img
src={viewerObjectUrl}
alt="{notification.title} attachment"
class="rounded-lg max-w-full max-h-[70vh] self-center"
/>
{:else if showingVideoPreview}
<!-- eslint-disable-next-line svelte/no-unused-svelte-ignore -->
<!-- svelte-ignore a11y_media_has_caption: attachment previews do not ship caption tracks -->
<video
src={viewerObjectUrl}
controls
muted
playsinline
preload="metadata"
class="rounded-lg max-w-full max-h-[70vh] self-center"
onerror={handleVideoError}
></video>
{:else}
<div
class="rounded-lg border border-dashed border-base-300 bg-base-200/60 px-4 py-10 text-center"
>
<p class="text-sm font-medium text-base-content">Preview not available</p>
<p class="mt-1 text-xs text-base-content/70">
Download the attachment to open it in another app.
</p>
</div>
{/if}
</div>
<form method="dialog" class="modal-backdrop"><button type="submit">close</button></form>
</dialog>
51 changes: 51 additions & 0 deletions web/apps/hive/src/lib/utils/attachmentCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const fetchBlobMock = vi.fn();
const decryptBinaryMock = vi.fn();

vi.mock('@beebuzz/shared/api', () => ({
fetchBlob: fetchBlobMock
}));

vi.mock('$lib/services/encryption', () => ({
decryptBinary: decryptBinaryMock
}));

describe('attachmentCache', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});

it('decrypts encrypted attachments into a cached Blob result', async () => {
const ciphertext = new Uint8Array([1, 2, 3]);
const plaintext = new Uint8Array([7, 8, 9]);
fetchBlobMock.mockResolvedValue(new Blob([ciphertext], { type: 'application/octet-stream' }));
decryptBinaryMock.mockResolvedValue(plaintext);

const { clearAttachmentCache, fetchAndCacheAttachment } = await import('./attachmentCache');
clearAttachmentCache();

const first = await fetchAndCacheAttachment('/attachments/token-1', 'video/mp4', true);
const second = await fetchAndCacheAttachment('/attachments/token-1', 'video/mp4', true);

expect(fetchBlobMock).toHaveBeenCalledTimes(1);
expect(decryptBinaryMock).toHaveBeenCalledTimes(1);
expect(first).toBe(second);
expect(first.mimeType).toBe('video/mp4');
expect(await first.blob.arrayBuffer()).toEqual(
await new Blob([plaintext], { type: 'video/mp4' }).arrayBuffer()
);
});

it('matches only the supported preview MIME types', async () => {
const { isImageMime, isVideoMime } = await import('./attachmentCache');

expect(isImageMime('image/png')).toBe(true);
expect(isImageMime('image/svg+xml')).toBe(false);
expect(isImageMime('image/webp; charset=utf-8')).toBe(true);
expect(isVideoMime('video/mp4')).toBe(true);
expect(isVideoMime('video/webm')).toBe(false);
expect(isVideoMime('video/mp4; codecs=avc1.42E01E,mp4a.40.2')).toBe(true);
});
});
Loading