Skip to content
Open
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
16 changes: 15 additions & 1 deletion docs/direct-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ Private messages use Nostr NIP-17, NIP-44 encryption and NIP-59 gift wraps.
- Replies use an `e` tag marked `reply`, referencing the original rumor ID.
- Reactions use wrapped `kind:7`; deletion requests use wrapped `kind:5` with the target `e` and `k` tags. Only the original author can delete a message.
- Edits delete the previous version and send a replacement at the original timestamp. Anagram's `e` tag marked `edit` links the versions; this marker is an application extension.
- Media uses Blossom URLs and `imeta` attachment tags. The message is encrypted; uploaded files are not encrypted by Anagram.
- Older media messages use Blossom URLs and `imeta` attachment tags on `kind:14`; those files are not encrypted. They keep rendering.

## Private media

- Images, video and audio are sent as `kind:15` file messages, in direct chats and groups alike. Allowed types, up to 20 MiB each: JPEG, PNG, GIF, WebP and AVIF images; MP4, MOV (QuickTime) and WebM video; MP3, MP4/AAC, Ogg, WebM, WAV and FLAC audio. SVG is never encrypted or rendered inline.
- The file is encrypted on the device with AES-256-GCM, with a fresh random 32-byte key and 12-byte nonce per encryption. Only the ciphertext is uploaded, as `application/octet-stream`, in a single PUT to the encrypted-media Blossom server (default `https://blossom.ditto.pub`). That server is chosen separately from the regular Blossom server and stored in the encrypted NIP-78 preferences. The returned hash must match the ciphertext and the blob URL must use HTTPS.
- The rumor content is the HTTPS blob URL. Tags: `file-type` (plaintext MIME type), `encryption-algorithm` (`aes-gcm`), `decryption-key` and `decryption-nonce` (lowercase hex), `x` (SHA-256 of the ciphertext), `ox` (SHA-256 of the original file) and `size` (ciphertext bytes). The key and nonce exist only inside the gift-wrapped rumor; the message-info view hides them. File metadata such as EXIF is encrypted with the file, not stripped.
- A failed upload names its cause (network, authorization, payment, size, file type, server, invalid response or hash mismatch) and offers Retry and Change server, which re-send the same ciphertext, key and nonce, Send without encryption, and Cancel. Nothing is retried or downgraded automatically. A server chosen during a retry is saved only after the `kind:15` message was sent.
- Media is only uploaded without encryption after the user chooses Send without encryption, for a failed upload or for a file type that cannot be encrypted (such as HEIC). A confirmation explains that the Blossom operator and anyone with the URL may see the file; "Don't ask again" skips only that confirmation and can be reset in Settings > Media & Data Storage. The original file then goes to the regular Blossom server and is sent as a `kind:14` message with an `imeta` tag, like other unencrypted media.
- Uploading needs explicit confirmation for picked, pasted and dropped media. Cancelling uploads nothing.
- An upload belongs to the account and chat it was chosen in. Switching chat, logging out or closing the chat view cancels it and drops the key; nothing is signed, sent or saved for a different account or chat, and a late result is discarded.
- Captions are not supported: the `kind:15` content is the blob URL, so the media is sent as its own message. Text in the composer is not included and stays in the draft to be sent as a separate message.
- Received `kind:15` rumors need a valid algorithm, 32-byte key, nonce (12 or 16 bytes), `file-type`, `x` and HTTPS URL; anything else is dropped and never shown as a link. Downloads are capped at the type's size limit plus the GCM tag, must match `x` before decryption, and a GCM authentication failure is a hard error. Supported media is rendered from a `blob:` URL typed with the allowlisted MIME type.
- Encrypted media follows the media trust setting: for the user's own messages and trusted senders it is decrypted once near the viewport; for others nothing is downloaded until the user chooses to load media.
- Reactions, deletions, relay retries and outbound replay treat `kind:15` like `kind:14`. `kind:15` messages are never edited. Forwarding re-sends the same URL, key and nonce in a new `kind:15` rumor without re-uploading. Clients that only understand `kind:14` do not show `kind:15` messages.

Deletion requests cannot erase copies already held by recipients.
20 changes: 18 additions & 2 deletions e2e/composer-paste.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ test('existing image paste opens upload confirmation in private and public chats
try {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await openDirectChatFromIdentifier(page, user.session.npub, 'My Self');
// Pasted media needs explicit confirmation, and cancelling it uploads nothing.
let uploadRequests = 0;
await page.route('**/upload', async (route) => {
uploadRequests += 1;
await route.abort();
});
async function pasteImage() {
await page.evaluate(async () => {
const canvas = document.createElement('canvas');
Expand All @@ -25,7 +31,7 @@ test('existing image paste opens upload confirmation in private and public chats
});
const input = page.getByTestId('message-composer-input');
await input.fill('Draft kept while pasting');
await input.press('Control+v');
await input.press('ControlOrMeta+v');
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('button', { name: 'Upload', exact: true })).toBeVisible();
await expect(dialog).toContainText('image.png');
Expand All @@ -34,6 +40,8 @@ test('existing image paste opens upload confirmation in private and public chats
await expect(dialog).toHaveCount(0);
}
await pasteImage();
expect(uploadRequests).toBe(0);
await expect(page.getByTestId('message-bubble')).toHaveCount(0);
await navigateInApp(page, '/chats');
await page.getByRole('button', { name: 'Chat options' }).click();
await page.getByRole('button', { name: 'New public group', exact: true }).click();
Expand All @@ -44,8 +52,10 @@ test('existing image paste opens upload confirmation in private and public chats
await pasteImage();
await page.evaluate(() => navigator.clipboard.writeText(' plain text'));
const input = page.getByTestId('message-composer-input');
await input.press('Control+v');
await input.press('ControlOrMeta+v');
await expect(input).toHaveValue('Draft kept while pasting plain text');
// Checked again after the public group paste, so a delayed private upload would show.
expect(uploadRequests).toBe(0);
expect(user.browserErrors).toEqual([]);
} finally {
await context.close();
Expand Down Expand Up @@ -80,6 +90,11 @@ test('Linux native fallback opens the existing confirmation without sending', as
await navigator.clipboard.writeText('');
});
await openDirectChatFromIdentifier(page, user.session.npub, 'My Self');
let uploadRequests = 0;
await page.route('**/upload', async (route) => {
uploadRequests += 1;
await route.abort();
});
const input = page.getByTestId('message-composer-input');
await input.fill('Native caption');
await input.press('Control+v');
Expand All @@ -89,6 +104,7 @@ test('Linux native fallback opens the existing confirmation without sending', as
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(input).toHaveValue('Native caption');
await expect(page.getByTestId('message-bubble')).toHaveCount(0);
expect(uploadRequests).toBe(0);
await page.evaluate(() => navigator.clipboard.writeText(' plus text'));
await input.press('Control+v');
await expect(input).toHaveValue('Native caption plus text');
Expand Down
70 changes: 40 additions & 30 deletions e2e/composer-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { expect, test, type Page } from '@playwright/test';
import {
bootstrapUser,
navigateToChat,
disposeUsers,
navigateInApp,
TEST_ACCOUNTS,
} from './parity/helpers';
import { bootstrapUser, navigateToChat, disposeUsers, TEST_ACCOUNTS } from './parity/helpers';

const server = 'https://images.example.org';
const image = {
Expand All @@ -16,14 +10,22 @@ const image = {
'base64',
),
};
// A Blossom server for encrypted private media: it stores what it receives and serves it back,
// so the client can only show the image if it decrypts what it uploaded.
async function uploads(page: Page, account: string) {
let count = 0;
let fail = false;
const bodies: Buffer[] = [];
const contentTypes: string[] = [];
let hold: Promise<void> | undefined;
let release = () => {};
await page.route(`${server}/**`, async (route) => {
if (route.request().method() !== 'PUT') {
await route.fulfill({ contentType: 'image/png', body: image.buffer });
const number = Number(new URL(route.request().url()).pathname.slice(1));
const body = bodies[number - 1];
await route.fulfill(
body
? { contentType: 'application/octet-stream', body }
: { status: 404, body: 'Not found' },
);
return;
}
expect(route.request().url()).toBe(`${server}/upload`);
Expand All @@ -33,31 +35,29 @@ async function uploads(page: Page, account: string) {
);
expect(auth.kind).toBe(24242);
expect(auth.pubkey).toBe(account);
const number = ++count;
const body = route.request().postDataBuffer()!;
bodies.push(body);
contentTypes.push(headers['content-type']);
const number = bodies.length;
await hold;
if (fail) {
await route.fulfill({ status: 500, body: 'Image upload failed' });
return;
}
await route.fulfill({
status: 201,
json: {
url: `${server}/${number}.png`,
url: `${server}/${number}`,
sha256: headers['x-sha-256'],
size: image.buffer.length,
type: 'image/png',
size: body.length,
type: 'application/octet-stream',
},
});
});
await page.evaluate(async (server) => {
const { useNostrStore } = await import('/src/stores/nostrStore.svelte.ts');
await useNostrStore().saveBlossomServerUrl(server);
await useNostrStore().savePrivateMediaBlossomServerUrl(server);
}, server);
return {
count: () => count,
fail: (value: boolean) => {
fail = value;
},
count: () => bodies.length,
bodies,
contentTypes,
hold: () => {
hold = new Promise<void>((resolve) => {
release = resolve;
Expand All @@ -69,8 +69,9 @@ async function uploads(page: Page, account: string) {
},
};
}

test('private upload can be cancelled and adds its link to a caption draft before sending', async ({
// Private media is encrypted and sent as its own NIP-17 kind 15 message, which has no caption:
// the composer text stays in the draft and is sent separately.
test('private upload can be cancelled, sends encrypted kind 15 media, and keeps the caption draft', async ({
browser,
}) => {
const user = await bootstrapUser(browser, TEST_ACCOUNTS.composerUpload);
Expand All @@ -84,6 +85,8 @@ test('private upload can be cancelled and adds its link to a caption draft befor
upload.hold();
await page.locator('input[type="file"]').setInputFiles(image);
const dialog = page.getByRole('dialog', { name: 'upload', exact: true });
await expect(dialog).toContainText(image.name);
await expect(dialog).toContainText('Text in the composer is not included');
await dialog.getByRole('button', { name: 'Upload', exact: true }).click();
await expect.poll(upload.count).toBe(1);
await dialog.getByRole('button', { name: 'Close dialog', exact: true }).click();
Expand All @@ -92,12 +95,19 @@ test('private upload can be cancelled and adds its link to a caption draft befor
await expect(rows).toHaveCount(0);
await page.locator('input[type="file"]').setInputFiles(image);
await dialog.getByRole('button', { name: 'Upload', exact: true }).click();
await expect(input).toHaveValue('My caption\nhttps://images.example.org/2.png');
await expect(rows).toHaveCount(0);
await input.fill('My finished caption\nhttps://images.example.org/2.png');
await expect(dialog).toBeHidden({ timeout: 30_000 });
// The cancelled upload was released before this one started; a late send would add a row.
await expect(rows).toHaveCount(1);
await expect(rows.getByTestId('message-encrypted-image')).toBeVisible({ timeout: 30_000 });
await expect(rows).not.toContainText('My caption');
expect(upload.count()).toBe(2);
expect(upload.contentTypes).toEqual(['application/octet-stream', 'application/octet-stream']);
for (const body of upload.bodies) expect(body.includes(image.buffer)).toBe(false);
await expect(input).toHaveValue('My caption');
await input.fill('My finished caption');
await page.getByTestId('message-send-button').click();
await expect(rows).toContainText('My finished caption');
await expect(rows.locator('img[src="https://images.example.org/2.png"]')).toBeVisible();
await expect(rows).toHaveCount(2);
await expect(rows.last()).toContainText('My finished caption');
await expect(input).toHaveValue('');
} finally {
await disposeUsers(user);
Expand Down
Binary file added e2e/fixtures/h264-clip.mov
Binary file not shown.
Binary file added e2e/fixtures/vp8-clip.webm
Binary file not shown.
Loading