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 { spawn, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
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'];
/** 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. */
const LINK_PREVIEW_TIMEOUT_MS = 10_000;
/** 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). */
async function pickImage(): Promise<{ path: string; name: string; mime: string }[]> {
/** Show an open dialog and return one-time tokens for the chosen images. */
async function pickImage(): Promise<{ token: string; name: string; mime: string }[]> {
const result = await dialog.showOpenDialog({
title: 'Attach image(s)',
filters: [{ name: 'Images', extensions: IMAGE_EXTENSIONS }],
@ -189,18 +212,21 @@ async function pickImage(): Promise<{ path: string; name: string; mime: string }
if (result.canceled) {
return [];
}
return result.filePaths.map((filePath) => ({
path: filePath,
name: path.basename(filePath),
mime: mimeForPath(filePath),
}));
return result.filePaths.map((filePath) => {
const file: PickedFile = {
path: 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. */
async function uploadImage(filePath: string): Promise<{ url: string; mime: string }> {
/** Upload a picked image to nostr.build and return the public URL + MIME type. */
async function uploadImage(file: PickedFile): Promise<{ url: string; mime: string }> {
const uploadUrl = 'https://nostr.build/api/v2/upload/files';
const data = readFileSync(filePath);
const mime = mimeForPath(filePath);
const data = readFileSync(file.path);
const mime = file.mime;
// nostr.build requires a NIP-98 auth token signed with the active profile's key.
const authEnvelope = (await backendRequest('upload_auth', {
@ -212,7 +238,11 @@ async function uploadImage(filePath: string): Promise<{ url: string; mime: strin
}
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, {
method: 'POST',
headers: { authorization: authEnvelope.data.authorization },
@ -407,17 +437,19 @@ app.whenReady().then(() => {
return { status: 'ok', data: url ? await fetchLinkPreview(url) : null };
}
if (payload.method === 'upload_image') {
const filePath = String(params.path ?? '');
if (!filePath) {
const token = typeof params.token === 'string' ? params.token : '';
const file = token ? pickedTokens.get(token) : undefined;
pickedTokens.delete(token);
if (!file) {
return {
status: 'error',
code: 'bad_request',
message: 'No image file given.',
code: 'unknown_token',
message: 'That image selection has expired. Please attach it again.',
details: null,
};
}
try {
return { status: 'ok', data: await uploadImage(filePath) };
return { status: 'ok', data: await uploadImage(file) };
} catch (error) {
return {
status: 'error',