Files
proof-of-work/proof-of-work/app/api/ai/ollama/models/route.ts
T
Keysat 988a3cca9a
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
v1.1.0:8 — admin-gate whole-DB routes + AI custom-URL providers; SSRF guard
Multi-user authorization hardening from a full security evaluation (EVALUATION.md):

- P0: /api/settings/{export,import}-db are now admin-only. Previously any signed-in user could download the whole instance DB (all bcrypt hashes + plaintext AI keys) or replace it wholesale. Per-user CSV export/import stays open.

- AI custom-URL providers (Ollama, OpenAI-compatible) are now admin-only, and every server fetch to a user-supplied URL passes through assertSafeProviderUrl (blocks link-local/cloud-metadata; private LAN allowed by design). Fixed-URL cloud providers stay per-user. Removed the dead legacy /api/ai/config route.

- Dev: fixed broken quick-start (added npm run create-admin; rewrote README; dropped dead CLAUDE_API_KEY) and the export-db 0-byte path resolution (resolveDatabasePath now matches Prisma).

ExVer bumped to 1.1.0:8 (no schema/data migration). Tests 197 pass, build green, tsc clean.
2026-06-12 23:15:09 -05:00

118 lines
3.4 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { assertSafeProviderUrl } from '@/lib/ai/safeUrl';
/**
* GET /api/ai/ollama/models?baseUrl=...
*
* Probes Ollama at the supplied baseUrl (or http://ollama.startos:11434
* by default) and returns the list of installed models, plus a status
* flag the UI uses to decide whether to:
* - pre-fill the URL field
* - render a model dropdown vs a free-text input
* - show a "no models installed yet" hint
*
* Authenticated route — we don't want unauthenticated visitors fingerprinting
* the local network.
*
* Response:
* { ok: true, baseUrl, models: [{ name, sizeBytes, modifiedAt }], ms }
* { ok: false, baseUrl, error, ms }
*/
const PROBE_TIMEOUT_MS = 5_000;
const DEFAULT_CANDIDATES = [
'http://ollama.startos:11434',
'http://ollama.embassy:11434',
];
export async function GET(request: NextRequest) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
// Probing Ollama URLs is the admin-only custom-URL surface (EVALUATION.md
// P1) — a non-admin shouldn't be able to fingerprint the local network.
if (!user.isAdmin)
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 });
const url = new URL(request.url);
const explicit = url.searchParams.get('baseUrl');
// If the caller specified a URL, probe just that. Otherwise walk the
// candidate list and return the first that responds (so the UI can
// auto-discover whether the user runs ollama.startos OR ollama.embassy).
const candidates = explicit ? [explicit] : DEFAULT_CANDIDATES;
for (const candidate of candidates) {
const result = await probe(candidate);
if (result.ok) return NextResponse.json(result);
// For an explicit URL, return the failure right away.
if (explicit) return NextResponse.json(result);
}
return NextResponse.json({
ok: false,
baseUrl: candidates[0],
error: 'No Ollama instance responded at the default StartOS addresses.',
ms: 0,
});
}
async function probe(baseUrl: string) {
const t0 = Date.now();
const url = baseUrl.replace(/\/$/, '') + '/api/tags';
try {
await assertSafeProviderUrl(url);
} catch (e) {
return {
ok: false as const,
baseUrl,
error: (e as Error).message,
ms: Date.now() - t0,
};
}
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ctrl.signal,
});
clearTimeout(timer);
if (!res.ok) {
return {
ok: false as const,
baseUrl,
error: `Ollama returned HTTP ${res.status}`,
ms: Date.now() - t0,
};
}
const body = (await res.json()) as {
models?: Array<{
name: string;
size?: number;
modified_at?: string;
}>;
};
return {
ok: true as const,
baseUrl,
models: (body.models ?? []).map((m) => ({
name: m.name,
sizeBytes: m.size ?? null,
modifiedAt: m.modified_at ?? null,
})),
ms: Date.now() - t0,
};
} catch (e) {
clearTimeout(timer);
return {
ok: false as const,
baseUrl,
error:
ctrl.signal.aborted
? `Timed out after ${PROBE_TIMEOUT_MS / 1000}s`
: (e as Error).message,
ms: Date.now() - t0,
};
}
}