Replace upload file paths with single-use pick tokens

This commit is contained in:
Avi 2026-08-21 13:48:34 -05:00
commit 6e627a3341
6 changed files with 62 additions and 27 deletions

View file

@ -1,5 +1,6 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol } from 'electron'; import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol } from 'electron';
import { spawn, type ChildProcess } from 'node:child_process'; import { spawn, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
import * as path from 'node:path'; import * as path from 'node:path';
@ -155,6 +156,28 @@ function isAllowedMethod(method: unknown): method is string {
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']; const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif'];
/** A file chosen through the native dialog, known only to the main process. */
interface PickedFile {
path: string;
name: string;
mime: string;
}
/**
* One-time tokens handed to the renderer in place of filesystem paths.
* `upload_image` accepts only these, so a compromised page can never point an
* upload at an arbitrary local file only at files the user explicitly picked,
* and each exactly once.
*/
const pickedTokens = new Map<string, PickedFile>();
/** Mint a single-use upload token for a freshly picked file. */
function issueToken(file: PickedFile): string {
const token = randomBytes(16).toString('hex');
pickedTokens.set(token, file);
return token;
}
/** How long to wait for a link-preview page before giving up. */ /** How long to wait for a link-preview page before giving up. */
const LINK_PREVIEW_TIMEOUT_MS = 10_000; const LINK_PREVIEW_TIMEOUT_MS = 10_000;
/** Cap on how much HTML we parse for meta tags. */ /** Cap on how much HTML we parse for meta tags. */
@ -179,8 +202,8 @@ function mimeForPath(filePath: string): string {
} }
} }
/** Show an open dialog and return the chosen image files (empty when cancelled). */ /** Show an open dialog and return one-time tokens for the chosen images. */
async function pickImage(): Promise<{ path: string; name: string; mime: string }[]> { async function pickImage(): Promise<{ token: string; name: string; mime: string }[]> {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
title: 'Attach image(s)', title: 'Attach image(s)',
filters: [{ name: 'Images', extensions: IMAGE_EXTENSIONS }], filters: [{ name: 'Images', extensions: IMAGE_EXTENSIONS }],
@ -189,18 +212,21 @@ async function pickImage(): Promise<{ path: string; name: string; mime: string }
if (result.canceled) { if (result.canceled) {
return []; return [];
} }
return result.filePaths.map((filePath) => ({ return result.filePaths.map((filePath) => {
path: filePath, const file: PickedFile = {
name: path.basename(filePath), path: filePath,
mime: mimeForPath(filePath), name: path.basename(filePath),
})); mime: mimeForPath(filePath),
};
return { token: issueToken(file), name: file.name, mime: file.mime };
});
} }
/** Upload one image file to nostr.build and return the public URL + MIME type. */ /** Upload a picked image to nostr.build and return the public URL + MIME type. */
async function uploadImage(filePath: string): Promise<{ url: string; mime: string }> { async function uploadImage(file: PickedFile): Promise<{ url: string; mime: string }> {
const uploadUrl = 'https://nostr.build/api/v2/upload/files'; const uploadUrl = 'https://nostr.build/api/v2/upload/files';
const data = readFileSync(filePath); const data = readFileSync(file.path);
const mime = mimeForPath(filePath); const mime = file.mime;
// nostr.build requires a NIP-98 auth token signed with the active profile's key. // nostr.build requires a NIP-98 auth token signed with the active profile's key.
const authEnvelope = (await backendRequest('upload_auth', { const authEnvelope = (await backendRequest('upload_auth', {
@ -212,7 +238,11 @@ async function uploadImage(filePath: string): Promise<{ url: string; mime: strin
} }
const form = new FormData(); const form = new FormData();
form.append('fileToUpload', new Blob([data], { type: mime }), path.basename(filePath) || 'image'); form.append(
'fileToUpload',
new Blob([data], { type: mime }),
path.basename(file.name) || 'image',
);
const response = await fetch(uploadUrl, { const response = await fetch(uploadUrl, {
method: 'POST', method: 'POST',
headers: { authorization: authEnvelope.data.authorization }, headers: { authorization: authEnvelope.data.authorization },
@ -407,17 +437,19 @@ app.whenReady().then(() => {
return { status: 'ok', data: url ? await fetchLinkPreview(url) : null }; return { status: 'ok', data: url ? await fetchLinkPreview(url) : null };
} }
if (payload.method === 'upload_image') { if (payload.method === 'upload_image') {
const filePath = String(params.path ?? ''); const token = typeof params.token === 'string' ? params.token : '';
if (!filePath) { const file = token ? pickedTokens.get(token) : undefined;
pickedTokens.delete(token);
if (!file) {
return { return {
status: 'error', status: 'error',
code: 'bad_request', code: 'unknown_token',
message: 'No image file given.', message: 'That image selection has expired. Please attach it again.',
details: null, details: null,
}; };
} }
try { try {
return { status: 'ok', data: await uploadImage(filePath) }; return { status: 'ok', data: await uploadImage(file) };
} catch (error) { } catch (error) {
return { return {
status: 'error', status: 'error',

View file

@ -75,7 +75,7 @@ export const api = {
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }), removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }), revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
pickImages: () => call<PickedImage[]>('pick_image'), pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }), uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }), linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }), signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'), signerDisconnect: () => call<SignerStatus>('signer_disconnect'),

View file

@ -105,10 +105,10 @@ export interface RevealedKey {
nsec: string; nsec: string;
} }
/** An image file selected via the native file dialog. */ /** An image selected via the native file dialog, referenced by upload token. */
export interface PickedImage { export interface PickedImage {
/** Absolute path on disk. */ /** One-time token that authorises exactly one `upload_image` call. */
path: string; token: string;
/** File name for display. */ /** File name for display. */
name: string; name: string;
/** Best-effort MIME type from the file extension. */ /** Best-effort MIME type from the file extension. */

View file

@ -105,7 +105,7 @@ export function ComposeScreen() {
const picked = await pickImages(); const picked = await pickImages();
const uploaded: Attachment[] = []; const uploaded: Attachment[] = [];
for (const image of picked) { for (const image of picked) {
const result = await uploadImage(image.path); const result = await uploadImage(image.token);
uploaded.push({ url: result.url, mime: result.mime, name: image.name }); uploaded.push({ url: result.url, mime: result.mime, name: image.name });
} }
setAttachments((prev) => [...prev, ...uploaded]); setAttachments((prev) => [...prev, ...uploaded]);

View file

@ -56,7 +56,7 @@ interface AppContextValue {
removeVaultPassword: (password: string) => Promise<AppState>; removeVaultPassword: (password: string) => Promise<AppState>;
revealSecretKey: (npub: string) => Promise<RevealedKey>; revealSecretKey: (npub: string) => Promise<RevealedKey>;
pickImages: () => Promise<PickedImage[]>; pickImages: () => Promise<PickedImage[]>;
uploadImage: (path: string) => Promise<UploadedImage>; uploadImage: (token: string) => Promise<UploadedImage>;
linkPreview: (url: string) => Promise<LinkPreview | null>; linkPreview: (url: string) => Promise<LinkPreview | null>;
signerConnect: (uri: string) => Promise<SignerStatus>; signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>; signerDisconnect: () => Promise<SignerStatus>;
@ -177,7 +177,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
); );
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []); const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
const pickImages = useCallback(() => api.pickImages(), []); const pickImages = useCallback(() => api.pickImages(), []);
const uploadImage = useCallback((path: string) => api.uploadImage(path), []); const uploadImage = useCallback((token: string) => api.uploadImage(token), []);
const linkPreview = useCallback((url: string) => api.linkPreview(url), []); const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []); const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []);
const signerDisconnect = useCallback(() => api.signerDisconnect(), []); const signerDisconnect = useCallback(() => api.signerDisconnect(), []);

View file

@ -32,8 +32,8 @@ export interface FakeBackend {
nextErrors: Record<string, { message: string; details?: string; code?: string }>; nextErrors: Record<string, { message: string; details?: string; code?: string }>;
/** Every request dispatched through `api.request`, in order. */ /** Every request dispatched through `api.request`, in order. */
requests: { method: string; params: Record<string, unknown> }[]; requests: { method: string; params: Record<string, unknown> }[];
/** Files returned by `pick_image`. */ /** Files returned by `pick_image` (token, name, mime — never paths). */
pickedImages: { path: string; name: string; mime: string }[]; pickedImages: { token: string; name: string; mime: string }[];
/** URLs returned by `upload_image`, one per call. */ /** URLs returned by `upload_image`, one per call. */
uploadUrls: string[]; uploadUrls: string[];
/** Current NIP-46 signer status. */ /** Current NIP-46 signer status. */
@ -92,7 +92,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
relayErrors: new Set(), relayErrors: new Set(),
nextErrors: {}, nextErrors: {},
requests: [], requests: [],
pickedImages: [{ path: '/tmp/picked.png', name: 'picked.png', mime: 'image/png' }], pickedImages: [{ token: 'fake-pick-token', name: 'picked.png', mime: 'image/png' }],
uploadUrls: ['https://cdn.nostr.build/i/uploaded.png'], uploadUrls: ['https://cdn.nostr.build/i/uploaded.png'],
signer: makeSignerStatus(), signer: makeSignerStatus(),
setSigner(next) { setSigner(next) {
@ -196,6 +196,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return [...backend.pickedImages]; return [...backend.pickedImages];
case 'upload_image': { case 'upload_image': {
if (typeof params.token !== 'string' || params.token === '') {
throw Object.assign(new Error('No upload token given.'), { code: 'unknown_token' });
}
const index = backend.requests.filter((r) => r.method === 'upload_image').length - 1; const index = backend.requests.filter((r) => r.method === 'upload_image').length - 1;
const url = const url =
backend.uploadUrls[index] ?? backend.uploadUrls[backend.uploadUrls.length - 1] ?? ''; backend.uploadUrls[index] ?? backend.uploadUrls[backend.uploadUrls.length - 1] ?? '';