Skip to content

Commit 9c09f0a

Browse files
authored
Add upload endpoint (#60)
1 parent 3dd8e0e commit 9c09f0a

12 files changed

Lines changed: 906 additions & 14 deletions

File tree

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,19 @@ features.
108108

109109
Vite's `tidewave` accepts the configuration options below:
110110

111-
- `allow_remote_access:` Tidewave only allows requests from localhost by
112-
default, even if your server listens on other interfaces, for security
113-
purposes. Read
111+
- `allowRemoteAccess`: Tidewave only allows requests from localhost by default,
112+
even if your server listens on other interfaces, for security purposes. Read
114113
[our security guidelines for more information and when to allow remote access](https://hexdocs.pm/tidewave/security.html)
115114
(if you know what you are doing)
115+
- `allowedOrigins`: a list of values matched against the `Origin` header to
116+
prevent cross origin and DNS rebinding attacks. Each value must be a string of
117+
shape `[scheme:]//host[:port]`, where both scheme and port are optional. The
118+
host may also start with `*`. Example: `["//localhost:8000", "//*.test"]`. By
119+
default, Tidewave allows Vite's configured server host and port, using
120+
`localhost` when Vite uses its implicit host
121+
- `tmpDir`: temporary directory Tidewave uses for screenshots and recordings.
122+
Defaults to `tmp`, storing files under `tmp/tidewave/screenshots` and
123+
`tmp/tidewave/recordings`
116124
- `team`: enable Tidewave Web for teams
117125

118126
## Available tools

src/core.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,14 @@ export function createExtractError(
142142
}
143143

144144
export interface TidewaveConfig {
145+
port?: number;
146+
host?: string;
145147
clientUrl?: string;
146148
allowRemoteAccess?: boolean;
149+
allowedOrigins?: string[];
147150
projectName?: string;
148151
framework?: string;
152+
tmpDir?: string;
149153
team?: {
150154
id?: string;
151155
token?: string;

src/http/handlers/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function createHandleConfig(
2020
tidewave_version: tidewavePackage.version,
2121
team: config.team || {},
2222
local_port: getLocalPort?.(),
23+
tmp_dir: config.tmpDir || 'tmp',
2324
};
2425

2526
res.statusCode = 200;

src/http/handlers/upload.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import path from 'node:path';
2+
import { mkdir, writeFile } from 'node:fs/promises';
3+
import type { TidewaveConfig } from '../../core';
4+
import type { TidewaveHandler, TidewaveNext, TidewaveRequest, TidewaveResponse } from '../types';
5+
import { magicByteType } from '../magic-bytes';
6+
import { checkOrigin } from '../security';
7+
8+
const MAX_UPLOAD_SIZE = 200_000_000;
9+
const ALLOWED_UPLOAD_CONTENT_TYPES = ['image/png', 'image/jpeg', 'video/webm'] as const;
10+
const ALLOWED_UPLOAD_TYPES = ['screenshot', 'recording'] as const;
11+
const ALLOWED_UPLOAD_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webm'] as const;
12+
const INVALID_UPLOAD = 'Bad Request: missing or invalid file parameter';
13+
14+
class InvalidUploadError extends Error {}
15+
16+
interface UploadedFile {
17+
readonly name: string;
18+
readonly type: string;
19+
arrayBuffer(): Promise<ArrayBuffer>;
20+
}
21+
22+
interface UploadFormData {
23+
get(name: string): unknown;
24+
}
25+
26+
export function createHandleUpload(config: TidewaveConfig): TidewaveHandler {
27+
return async function handleUpload(
28+
req: TidewaveRequest,
29+
res: TidewaveResponse,
30+
next: TidewaveNext,
31+
): Promise<void> {
32+
try {
33+
if (req.method !== 'POST') {
34+
notFound(res);
35+
return;
36+
}
37+
38+
if (!checkOrigin(req, res, config)) return;
39+
if (uploadTooLarge(req)) {
40+
badRequest(res);
41+
return;
42+
}
43+
44+
const formData = await parseFormData(req);
45+
const type = formData.get('type');
46+
const file = formData.get('file');
47+
48+
if (!isAllowedUploadType(type) || !isUploadedFile(file)) {
49+
badRequest(res);
50+
return;
51+
}
52+
53+
const buffer = Buffer.from(await file.arrayBuffer());
54+
55+
if (!isAllowedUpload(file, buffer)) {
56+
badRequest(res);
57+
return;
58+
}
59+
60+
const destination = uploadPath(config, type, file.name);
61+
if (!destination) {
62+
badRequest(res);
63+
return;
64+
}
65+
66+
await mkdir(uploadDir(config, type), { recursive: true });
67+
await writeFile(destination, buffer);
68+
69+
res.statusCode = 200;
70+
res.setHeader('Content-Type', 'application/json');
71+
res.end(JSON.stringify({ status: 'ok', path: destination }));
72+
} catch (err) {
73+
if (err instanceof InvalidUploadError) {
74+
badRequest(res);
75+
return;
76+
}
77+
78+
console.error(`[Tidewave] Failed to handle upload: ${err}`);
79+
80+
if (!res.headersSent) {
81+
res.statusCode = 500;
82+
res.end('Internal server error');
83+
}
84+
85+
next(err);
86+
}
87+
};
88+
}
89+
90+
function uploadTooLarge(req: TidewaveRequest): boolean {
91+
const contentLength = firstHeaderValue(req.headers['content-length']);
92+
if (!contentLength) return false;
93+
94+
const size = Number(contentLength);
95+
return Number.isFinite(size) && size > MAX_UPLOAD_SIZE;
96+
}
97+
98+
async function parseFormData(req: TidewaveRequest): Promise<UploadFormData> {
99+
const body = await readRequestBody(req);
100+
const request = new Request(requestUrl(req), {
101+
method: req.method,
102+
headers: requestHeaders(req),
103+
body: toArrayBuffer(body),
104+
});
105+
106+
try {
107+
return await request.formData();
108+
} catch {
109+
throw new InvalidUploadError(INVALID_UPLOAD);
110+
}
111+
}
112+
113+
async function readRequestBody(req: TidewaveRequest): Promise<Buffer> {
114+
const chunks: Buffer[] = [];
115+
let size = 0;
116+
117+
for await (const chunk of req) {
118+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
119+
size += buffer.length;
120+
121+
if (size > MAX_UPLOAD_SIZE) {
122+
throw new InvalidUploadError(INVALID_UPLOAD);
123+
}
124+
125+
chunks.push(buffer);
126+
}
127+
128+
return Buffer.concat(chunks);
129+
}
130+
131+
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
132+
const arrayBuffer = new ArrayBuffer(buffer.byteLength);
133+
new Uint8Array(arrayBuffer).set(buffer);
134+
return arrayBuffer;
135+
}
136+
137+
function requestUrl(req: TidewaveRequest): string {
138+
const host = firstHeaderValue(req.headers.host) || 'localhost';
139+
return `http://${host}${req.url || '/'}`;
140+
}
141+
142+
function requestHeaders(req: TidewaveRequest): Headers {
143+
const headers = new Headers();
144+
145+
for (const [name, value] of Object.entries(req.headers)) {
146+
if (Array.isArray(value)) {
147+
for (const item of value) headers.append(name, item);
148+
} else if (value !== undefined) {
149+
headers.set(name, value);
150+
}
151+
}
152+
153+
return headers;
154+
}
155+
156+
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
157+
if (Array.isArray(value)) return value[0];
158+
return value;
159+
}
160+
161+
function isAllowedUploadType(type: unknown): type is (typeof ALLOWED_UPLOAD_TYPES)[number] {
162+
return typeof type === 'string' && isOneOf(ALLOWED_UPLOAD_TYPES, type);
163+
}
164+
165+
function isUploadedFile(file: unknown): file is UploadedFile {
166+
if (typeof file !== 'object' || file === null) return false;
167+
168+
const candidate = file as Partial<UploadedFile>;
169+
return (
170+
typeof candidate.name === 'string' &&
171+
typeof candidate.type === 'string' &&
172+
typeof candidate.arrayBuffer === 'function'
173+
);
174+
}
175+
176+
function isAllowedUpload(file: UploadedFile, buffer: Buffer): boolean {
177+
const [contentType] = file.type.split(';');
178+
179+
return (
180+
isOneOf(ALLOWED_UPLOAD_CONTENT_TYPES, contentType || '') &&
181+
magicByteType(buffer.subarray(0, 128)) !== 'unknown'
182+
);
183+
}
184+
185+
function uploadDir(config: TidewaveConfig, type: (typeof ALLOWED_UPLOAD_TYPES)[number]): string {
186+
return path.join(tmpDir(config), 'tidewave', folderForType(type));
187+
}
188+
189+
function uploadPath(
190+
config: TidewaveConfig,
191+
type: (typeof ALLOWED_UPLOAD_TYPES)[number],
192+
filename: string,
193+
): string | null {
194+
if (!/^[A-Za-z0-9_.-]+$/.test(filename)) return null;
195+
196+
const ext = path.extname(filename).toLowerCase();
197+
if (!isOneOf(ALLOWED_UPLOAD_EXTENSIONS, ext)) return null;
198+
199+
return path.join(uploadDir(config, type), filename);
200+
}
201+
202+
function isOneOf<const T extends readonly string[]>(values: T, value: string): value is T[number] {
203+
return values.includes(value);
204+
}
205+
206+
function tmpDir(config: TidewaveConfig): string {
207+
return config.tmpDir || 'tmp';
208+
}
209+
210+
function folderForType(type: (typeof ALLOWED_UPLOAD_TYPES)[number]): string {
211+
switch (type) {
212+
case 'screenshot':
213+
return 'screenshots';
214+
case 'recording':
215+
return 'recordings';
216+
}
217+
}
218+
219+
function badRequest(res: TidewaveResponse): void {
220+
res.statusCode = 400;
221+
res.end(INVALID_UPLOAD);
222+
}
223+
224+
function notFound(res: TidewaveResponse): void {
225+
res.statusCode = 404;
226+
res.end('Not Found');
227+
}

src/http/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { checkRemoteIp } from './security';
22
import { handleMcp } from './handlers/mcp';
33
import { createHandleHtml } from './handlers/html';
44
import { createHandleConfig, type LocalPortGetter } from './handlers/config';
5+
import { createHandleUpload } from './handlers/upload';
56
import bodyParser from 'body-parser';
67
import type { TidewaveConfig } from '../core';
78
import type {
@@ -30,6 +31,7 @@ export function configureServer(
3031
server.use(`${ENDPOINT}`, securityChecker);
3132
server.use(`${ENDPOINT}/`, createHandleHtml(config));
3233
server.use(`${ENDPOINT}/config`, createHandleConfig(config, options.getLocalPort));
34+
server.use(`${ENDPOINT}/upload`, createHandleUpload(config));
3335
server.use(`${ENDPOINT}/mcp`, bodyParser.json());
3436
server.use(`${ENDPOINT}/mcp`, handleMcp);
3537

src/http/magic-bytes.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type MagicByteType = 'jpg' | 'png' | 'webm' | 'unknown';
2+
3+
export function magicByteType(bytes: Uint8Array): MagicByteType {
4+
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return 'jpg';
5+
6+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
7+
return 'png';
8+
}
9+
10+
if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) {
11+
return includesAscii(bytes, 'webm') ? 'webm' : 'unknown';
12+
}
13+
14+
return 'unknown';
15+
}
16+
17+
function startsWith(bytes: Uint8Array, prefix: number[]): boolean {
18+
if (bytes.length < prefix.length) return false;
19+
20+
return prefix.every((byte, index) => bytes[index] === byte);
21+
}
22+
23+
function includesAscii(bytes: Uint8Array, text: string): boolean {
24+
const needle = Buffer.from(text, 'ascii');
25+
return Buffer.from(bytes).includes(needle);
26+
}

src/http/security.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,81 @@ export function checkRemoteIp(
3939
return false;
4040
}
4141

42+
export function checkOrigin(
43+
req: TidewaveRequest,
44+
res: TidewaveResponse,
45+
config: TidewaveConfig,
46+
): boolean {
47+
const { origin } = req.headers;
48+
49+
// No origin header means non-browser request (e.g. Claude Code, Cursor)
50+
if (!origin) return true;
51+
52+
const allowedOrigins = config.allowedOrigins || getDefaultAllowedOrigins(config);
53+
const originUrl = parseUrl(origin);
54+
55+
if (!originUrl) {
56+
const message = `For security reasons, Tidewave only accepts requests from allowed origins.\n\nInvalid origin: ${origin}`;
57+
console.warn(message);
58+
res.statusCode = 403;
59+
res.end(message);
60+
return false;
61+
}
62+
63+
const isAllowed = allowedOrigins.some(allowed => isOriginAllowed(originUrl, parseUrl(allowed)));
64+
65+
if (!isAllowed) {
66+
const message = `For security reasons, Tidewave only accepts requests from the same origin your web app is running on.\n\nIf you really want to allow remote connections, configure the Tidewave with the \`allowedOrigins: [${JSON.stringify(origin)}]\` option.`;
67+
console.warn(message);
68+
res.statusCode = 403;
69+
res.end(message);
70+
return false;
71+
}
72+
73+
return true;
74+
}
75+
76+
export function getDefaultAllowedOrigins(config: TidewaveConfig): string[] {
77+
const { host, port } = config;
78+
if (!(host || port)) return [];
79+
return [`http://${host}:${port}`, `https://${host}:${port}`];
80+
}
81+
82+
export function parseUrl(url: string): { scheme?: string; host: string; port?: number } | null {
83+
try {
84+
const isProtocolRelative = url.startsWith('//');
85+
const parsed = new URL(isProtocolRelative ? 'http:' + url : url);
86+
return {
87+
scheme: isProtocolRelative ? undefined : parsed.protocol?.slice(0, -1),
88+
host: parsed.hostname,
89+
port: parsed.port ? parseInt(parsed.port) : undefined,
90+
};
91+
} catch {
92+
return null;
93+
}
94+
}
95+
96+
export function isOriginAllowed(
97+
origin: ReturnType<typeof parseUrl>,
98+
allowed: ReturnType<typeof parseUrl>,
99+
): boolean {
100+
if (!origin || !allowed) return false;
101+
102+
// Check scheme (if specified in allowed)
103+
if (allowed.scheme && origin.scheme !== allowed.scheme) return false;
104+
105+
// Check port (if specified in allowed)
106+
if (allowed.port && origin.port !== allowed.port) return false;
107+
108+
// Check host with wildcard support
109+
if (allowed.host.startsWith('*.')) {
110+
const allowedDomain = allowed.host.slice(2);
111+
return origin.host === allowedDomain || origin.host.endsWith('.' + allowedDomain);
112+
}
113+
114+
return origin.host === allowed.host;
115+
}
116+
42117
export function isLocalIp(ip?: string): boolean {
43118
if (!ip) return false;
44119

0 commit comments

Comments
 (0)