Skip to content

Commit c8392da

Browse files
authored
Merge pull request #68 from tokenhost/issue-62/pr-06-upload-abstraction
[Issue 62 6/8] Upload abstraction and Filecoin runner support
2 parents 755cc39 + 450b0d3 commit c8392da

8 files changed

Lines changed: 1516 additions & 67 deletions

File tree

examples/upload-adapters/README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Upload Adapter Examples
2+
3+
## `foc-remote-adapter.mjs`
4+
5+
Standalone Node upload adapter that speaks the same request/response contract used by generated Token Host UIs.
6+
7+
It supports:
8+
- `local` mode
9+
- stores files on disk
10+
- returns absolute URLs from the adapter service
11+
- `foc-process` mode
12+
- shells out to `foc-cli upload --format json`
13+
- returns normalized Filecoin Onchain Cloud upload metadata
14+
15+
### Start it
16+
17+
```bash
18+
HOST=127.0.0.1 \
19+
PORT=8788 \
20+
TH_UPLOAD_ADAPTER_MODE=local \
21+
TH_UPLOAD_ENDPOINT_PATH=/api/upload \
22+
TH_UPLOAD_STATUS_PATH=/api/upload/status \
23+
TH_UPLOAD_PUBLIC_BASE_URL=http://127.0.0.1:8788 \
24+
node examples/upload-adapters/foc-remote-adapter.mjs
25+
```
26+
27+
### Point a generated app at it
28+
29+
```bash
30+
TH_UPLOAD_RUNNER=remote \
31+
TH_UPLOAD_REMOTE_ENDPOINT_URL=http://127.0.0.1:8788/api/upload \
32+
TH_UPLOAD_REMOTE_STATUS_URL=http://127.0.0.1:8788/api/upload/status \
33+
th build apps/example/microblog.schema.json --out out/microblog
34+
```
35+
36+
### Local-mode env
37+
38+
- `TH_UPLOAD_ADAPTER_MODE=local`
39+
- `TH_UPLOAD_LOCAL_DIR`
40+
directory root for stored uploads
41+
- `TH_UPLOAD_ENDPOINT_PATH`
42+
upload POST path
43+
- `TH_UPLOAD_STATUS_PATH`
44+
GET/HEAD status path
45+
- `TH_UPLOAD_PUBLIC_BASE_URL`
46+
absolute base URL used to construct returned file URLs
47+
- `TH_UPLOAD_ACCEPT`
48+
comma-separated MIME allowlist
49+
- `TH_UPLOAD_MAX_BYTES`
50+
request size limit
51+
52+
### FOC process-mode env
53+
54+
- `TH_UPLOAD_ADAPTER_MODE=foc-process`
55+
- `TH_UPLOAD_FOC_COMMAND`
56+
default `npx -y foc-cli`
57+
- `TH_UPLOAD_FOC_CHAIN`
58+
default `314159`
59+
- `TH_UPLOAD_FOC_COPIES`
60+
- `TH_UPLOAD_FOC_WITH_CDN`
61+
62+
### Response contract
63+
64+
Status response:
65+
66+
```json
67+
{
68+
"ok": true,
69+
"enabled": true,
70+
"provider": "filecoin_onchain_cloud",
71+
"runnerMode": "foc-process",
72+
"endpointUrl": "https://uploads.example.com/api/upload",
73+
"statusUrl": "https://uploads.example.com/api/upload/status",
74+
"accept": ["image/png", "image/jpeg"],
75+
"maxBytes": 10485760
76+
}
77+
```
78+
79+
Upload response:
80+
81+
```json
82+
{
83+
"ok": true,
84+
"upload": {
85+
"url": "https://... or http://.../uploads/...",
86+
"cid": null,
87+
"size": 12345,
88+
"provider": "local_file",
89+
"runnerMode": "local",
90+
"contentType": "image/png",
91+
"metadata": {}
92+
}
93+
}
94+
```
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
#!/usr/bin/env node
2+
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import http from 'node:http';
7+
import { spawnSync } from 'node:child_process';
8+
9+
function parsePositiveInt(value, fallback) {
10+
const parsed = Number.parseInt(String(value ?? ''), 10);
11+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
12+
}
13+
14+
function parseBoolean(value, fallback = false) {
15+
const normalized = String(value ?? '').trim().toLowerCase();
16+
if (!normalized) return fallback;
17+
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
18+
}
19+
20+
function trimTrailingSlash(value) {
21+
return String(value ?? '').trim().replace(/\/+$/, '');
22+
}
23+
24+
function normalizeUploadFileName(fileName) {
25+
const base = path.basename(fileName || 'upload.bin').replace(/[^A-Za-z0-9._-]+/g, '-');
26+
return base || 'upload.bin';
27+
}
28+
29+
function detectUploadExtension(fileName, contentType) {
30+
const ext = path.extname(fileName).toLowerCase();
31+
if (ext) return ext;
32+
switch (contentType) {
33+
case 'image/png':
34+
return '.png';
35+
case 'image/jpeg':
36+
return '.jpg';
37+
case 'image/gif':
38+
return '.gif';
39+
case 'image/webp':
40+
return '.webp';
41+
case 'image/svg+xml':
42+
return '.svg';
43+
default:
44+
return '.bin';
45+
}
46+
}
47+
48+
function shellQuote(value) {
49+
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
50+
}
51+
52+
function normalizeFocUploadResult(parsed) {
53+
const result = parsed?.result;
54+
const copyResults = Array.isArray(result?.copyResults) ? result.copyResults : [];
55+
const firstCopy = copyResults.find((entry) => entry && typeof entry.url === 'string' && entry.url.trim()) ?? null;
56+
const url = firstCopy ? String(firstCopy.url) : '';
57+
if (!url) {
58+
throw new Error('foc-cli upload did not return a usable copyResults[].url value.');
59+
}
60+
61+
return {
62+
url,
63+
cid: result?.pieceCid ? String(result.pieceCid) : null,
64+
size: Number.isFinite(Number(result?.size)) ? Number(result.size) : null,
65+
metadata: {
66+
pieceScannerUrl: result?.pieceScannerUrl ? String(result.pieceScannerUrl) : null,
67+
copyResults,
68+
copyFailures: Array.isArray(result?.copyFailures) ? result.copyFailures : []
69+
}
70+
};
71+
}
72+
73+
function runFocCliUpload(config, filePath) {
74+
const command =
75+
`${config.command} upload ${shellQuote(filePath)} --format json --chain ${config.chainId} --copies ${config.copies}` +
76+
`${config.withCDN ? ' --withCDN true' : ''}`;
77+
const result = spawnSync(command, {
78+
shell: true,
79+
encoding: 'utf-8',
80+
maxBuffer: 10 * 1024 * 1024
81+
});
82+
if (result.status !== 0) {
83+
throw new Error(String(result.stderr || result.stdout || `foc-cli failed with status ${result.status}`));
84+
}
85+
return normalizeFocUploadResult(JSON.parse(String(result.stdout || '{}')));
86+
}
87+
88+
function readBinaryBody(req, maxBytes) {
89+
return new Promise((resolve, reject) => {
90+
const chunks = [];
91+
let total = 0;
92+
req.on('data', (chunk) => {
93+
total += chunk.length;
94+
if (total > maxBytes) {
95+
reject(new Error('Request body too large.'));
96+
req.destroy();
97+
return;
98+
}
99+
chunks.push(Buffer.from(chunk));
100+
});
101+
req.on('end', () => resolve(Buffer.concat(chunks)));
102+
req.on('error', reject);
103+
});
104+
}
105+
106+
function contentTypeForPath(filePath) {
107+
switch (path.extname(filePath).toLowerCase()) {
108+
case '.html':
109+
return 'text/html; charset=utf-8';
110+
case '.json':
111+
return 'application/json; charset=utf-8';
112+
case '.png':
113+
return 'image/png';
114+
case '.jpg':
115+
case '.jpeg':
116+
return 'image/jpeg';
117+
case '.gif':
118+
return 'image/gif';
119+
case '.webp':
120+
return 'image/webp';
121+
case '.svg':
122+
return 'image/svg+xml';
123+
default:
124+
return 'application/octet-stream';
125+
}
126+
}
127+
128+
function sendJson(res, status, value) {
129+
res.statusCode = status;
130+
res.setHeader('content-type', 'application/json; charset=utf-8');
131+
res.setHeader('cache-control', 'no-store');
132+
res.end(JSON.stringify(value));
133+
}
134+
135+
function sendText(res, status, value) {
136+
res.statusCode = status;
137+
res.setHeader('content-type', 'text/plain; charset=utf-8');
138+
res.setHeader('cache-control', 'no-store');
139+
res.end(value);
140+
}
141+
142+
function safeResolveWithin(rootDir, pathname) {
143+
const candidate = path.resolve(rootDir, `.${pathname}`);
144+
if (!candidate.startsWith(path.resolve(rootDir))) return null;
145+
return candidate;
146+
}
147+
148+
const host = String(process.env.HOST ?? '127.0.0.1').trim() || '127.0.0.1';
149+
const port = parsePositiveInt(process.env.PORT, 8788);
150+
const runnerMode = String(process.env.TH_UPLOAD_ADAPTER_MODE ?? process.env.TH_UPLOAD_RUNNER ?? 'local').trim().toLowerCase() === 'foc-process'
151+
? 'foc-process'
152+
: 'local';
153+
const endpointPath = (() => {
154+
const raw = String(process.env.TH_UPLOAD_ENDPOINT_PATH ?? '/__tokenhost/upload').trim() || '/__tokenhost/upload';
155+
return raw.startsWith('/') ? raw : `/${raw}`;
156+
})();
157+
const statusPath = (() => {
158+
const raw = String(process.env.TH_UPLOAD_STATUS_PATH ?? endpointPath).trim() || endpointPath;
159+
return raw.startsWith('/') ? raw : `/${raw}`;
160+
})();
161+
const publicBaseUrl = trimTrailingSlash(process.env.TH_UPLOAD_PUBLIC_BASE_URL || `http://${host}:${port}`);
162+
const storagePath = (() => {
163+
const raw = String(process.env.TH_UPLOAD_LOCAL_DIR ?? path.join(process.cwd(), '.tokenhost-upload-adapter')).trim();
164+
return path.resolve(raw, 'uploads');
165+
})();
166+
const publicUploadsPath = '/uploads';
167+
const accept = String(process.env.TH_UPLOAD_ACCEPT ?? 'image/png,image/jpeg,image/gif,image/webp,image/svg+xml')
168+
.split(',')
169+
.map((entry) => entry.trim())
170+
.filter(Boolean);
171+
const maxBytes = parsePositiveInt(process.env.TH_UPLOAD_MAX_BYTES, 10 * 1024 * 1024);
172+
const focConfig = {
173+
chainId: parsePositiveInt(process.env.TH_UPLOAD_FOC_CHAIN, 314159),
174+
copies: parsePositiveInt(process.env.TH_UPLOAD_FOC_COPIES, 2),
175+
withCDN: parseBoolean(process.env.TH_UPLOAD_FOC_WITH_CDN, false),
176+
command: String(process.env.TH_UPLOAD_FOC_COMMAND ?? 'npx -y foc-cli').trim() || 'npx -y foc-cli'
177+
};
178+
179+
fs.mkdirSync(storagePath, { recursive: true });
180+
181+
const server = http.createServer((req, res) => {
182+
if (!req.url) return sendText(res, 400, 'Bad Request');
183+
184+
const pathname = new URL(req.url, `http://${host}:${port}`).pathname || '/';
185+
186+
if (pathname === endpointPath || pathname === statusPath) {
187+
(async () => {
188+
if (req.method === 'GET' || req.method === 'HEAD') {
189+
return sendJson(res, 200, {
190+
ok: true,
191+
enabled: true,
192+
provider: runnerMode === 'foc-process' ? 'filecoin_onchain_cloud' : 'local_file',
193+
runnerMode,
194+
endpointUrl: `${publicBaseUrl}${endpointPath}`,
195+
statusUrl: `${publicBaseUrl}${statusPath}`,
196+
accept,
197+
maxBytes
198+
});
199+
}
200+
201+
if (req.method !== 'POST') {
202+
res.setHeader('allow', 'GET, HEAD, POST');
203+
return sendText(res, 405, 'Method Not Allowed');
204+
}
205+
206+
try {
207+
const fileName = normalizeUploadFileName(String(req.headers['x-tokenhost-upload-filename'] ?? 'upload.bin'));
208+
const contentType = String(req.headers['content-type'] ?? 'application/octet-stream').split(';')[0].trim().toLowerCase();
209+
if (accept.length > 0) {
210+
const supported = accept.some((pattern) => pattern === contentType || (pattern.endsWith('/*') && contentType.startsWith(pattern.slice(0, -1))));
211+
if (!supported) return sendJson(res, 415, { ok: false, error: `Unsupported content type "${contentType}".` });
212+
}
213+
214+
const body = await readBinaryBody(req, maxBytes);
215+
if (!body.length) return sendJson(res, 400, { ok: false, error: 'Empty upload body.' });
216+
217+
if (runnerMode === 'foc-process') {
218+
const ext = detectUploadExtension(fileName, contentType);
219+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-foc-remote-'));
220+
const tempFile = path.join(tempDir, `upload${ext}`);
221+
fs.writeFileSync(tempFile, body);
222+
try {
223+
const uploaded = runFocCliUpload(focConfig, tempFile);
224+
return sendJson(res, 200, {
225+
ok: true,
226+
upload: {
227+
url: uploaded.url,
228+
cid: uploaded.cid,
229+
size: uploaded.size ?? body.length,
230+
provider: 'filecoin_onchain_cloud',
231+
runnerMode: 'foc-process',
232+
contentType,
233+
metadata: uploaded.metadata
234+
}
235+
});
236+
} finally {
237+
fs.rmSync(tempDir, { recursive: true, force: true });
238+
}
239+
}
240+
241+
const ext = detectUploadExtension(fileName, contentType);
242+
const storedName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
243+
const storedPath = path.join(storagePath, storedName);
244+
fs.writeFileSync(storedPath, body);
245+
return sendJson(res, 200, {
246+
ok: true,
247+
upload: {
248+
url: `${publicBaseUrl}${publicUploadsPath}/${storedName}`,
249+
cid: null,
250+
size: body.length,
251+
provider: 'local_file',
252+
runnerMode: 'local',
253+
contentType,
254+
metadata: {
255+
storedName
256+
}
257+
}
258+
});
259+
} catch (error) {
260+
return sendJson(res, 400, { ok: false, error: String(error?.message ?? error) });
261+
}
262+
})();
263+
return;
264+
}
265+
266+
if (pathname.startsWith(`${publicUploadsPath}/`)) {
267+
const filePath = safeResolveWithin(storagePath, pathname.slice(publicUploadsPath.length));
268+
if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
269+
return sendText(res, 404, 'Not Found');
270+
}
271+
res.statusCode = 200;
272+
res.setHeader('content-type', contentTypeForPath(filePath));
273+
fs.createReadStream(filePath).pipe(res);
274+
return;
275+
}
276+
277+
if (pathname === '/' || pathname === '/healthz') {
278+
return sendJson(res, 200, {
279+
ok: true,
280+
service: 'tokenhost-upload-adapter-example',
281+
runnerMode,
282+
endpointUrl: `${publicBaseUrl}${endpointPath}`,
283+
statusUrl: `${publicBaseUrl}${statusPath}`
284+
});
285+
}
286+
287+
return sendText(res, 404, 'Not Found');
288+
});
289+
290+
server.listen(port, host, () => {
291+
console.log(`Token Host upload adapter listening at http://${host}:${port}`);
292+
console.log(`Upload endpoint: ${publicBaseUrl}${endpointPath}`);
293+
console.log(`Status endpoint: ${publicBaseUrl}${statusPath}`);
294+
});

0 commit comments

Comments
 (0)