@accounter/scraper-app
Version:
Scraper app with Fastify server and React UI
145 lines (133 loc) • 5.04 kB
text/typescript
import { readFile, rename, writeFile } from 'node:fs/promises';
import type { FastifyInstance } from 'fastify';
import { registerAccountsRoutes } from './accounts-routes.js';
import { registerSettingsRoutes } from './settings-routes.js';
import { registerSourcesRoutes } from './sources-routes.js';
import { getVault, hasVaultFile, isLocked, lockVault, unlockVault } from './vault-store.js';
import { defaultVault, getVaultPath, saveVaultFile } from './vault.js';
type UnlockBody = { password: string };
type CreateBody = { password: string; serverUrl: string; apiKey: string };
export async function registerVaultRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/vault/status', async () => ({
locked: isLocked(),
hasFile: await hasVaultFile(),
}));
app.post<{ Body: UnlockBody }>(
'/api/vault/unlock',
{
schema: {
body: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string' } },
},
},
},
async (req, reply) => {
const result = await unlockVault(req.body.password);
if (result === 'ok') return { ok: true };
const status = result === 'not-found' ? 404 : 401;
return reply.status(status).send({ error: result });
},
);
app.post<{ Body: CreateBody }>(
'/api/vault/create',
{
schema: {
body: {
type: 'object',
required: ['password', 'serverUrl', 'apiKey'],
properties: {
password: { type: 'string' },
serverUrl: { type: 'string' },
apiKey: { type: 'string' },
},
},
},
},
async (req, reply) => {
if (await hasVaultFile()) return reply.status(409).send({ error: 'vault-already-exists' });
const { password, serverUrl, apiKey } = req.body;
lockVault();
const vault = defaultVault();
vault.settings.serverUrl = serverUrl;
vault.settings.apiKey = apiKey;
await saveVaultFile(getVaultPath(), vault, password);
const result = await unlockVault(password);
if (result !== 'ok') return reply.status(500).send({ error: result });
return reply.status(201).send({ ok: true });
},
);
app.get('/api/vault/env-path', async () => ({
path: getVaultPath(),
}));
app.get('/api/vault/download', async (_req, reply) => {
try {
const fileBuffer = await readFile(getVaultPath());
reply.header('Content-Type', 'application/octet-stream');
reply.header('Content-Disposition', 'attachment; filename=".vault"');
return reply.send(fileBuffer);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return reply.status(404).send({ error: 'no-vault-file' });
}
throw err;
}
});
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) =>
done(null, body),
);
app.post<{ Querystring: { force?: string }; Body: Buffer }>(
'/api/vault/upload',
{ config: { rawBody: false } },
async (req, reply) => {
const force = req.query.force === 'true';
const vaultPath = getVaultPath();
try {
const exists = await hasVaultFile();
if (exists && !force) {
return reply.status(409).send({ error: 'vault-already-exists' });
}
await writeFile(vaultPath + '.tmp', req.body as Buffer);
await rename(vaultPath + '.tmp', vaultPath);
lockVault();
return reply.status(200).send({ ok: true });
} catch (err) {
req.log.error(err, 'Vault upload failed');
return reply.status(500).send({ error: 'write-failed' });
}
},
);
app.get('/api/vault/path', async (_req, reply) => {
if (isLocked()) return reply.status(401).send({ error: 'vault-locked' });
return { path: getVaultPath() };
});
app.get('/api/vault/test-connection', async (_req, reply) => {
if (isLocked()) return reply.status(401).send({ error: 'vault-locked' });
const { serverUrl, apiKey } = getVault().settings;
if (!serverUrl || !apiKey) {
return reply.status(400).send({ error: 'Server URL and API key are not configured' });
}
const t0 = Date.now();
try {
const res = await fetch(serverUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ query: '{ __typename }' }),
signal: AbortSignal.timeout(10_000),
});
const latencyMs = Date.now() - t0;
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
return { ok: false, error: `HTTP ${res.status}: ${text}`, latencyMs };
}
return { ok: true, latencyMs };
} catch (err) {
const latencyMs = Date.now() - t0;
return { ok: false, error: err instanceof Error ? err.message : String(err), latencyMs };
}
});
await registerSourcesRoutes(app);
await registerSettingsRoutes(app);
await registerAccountsRoutes(app);
}