988a3cca9a
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.
118 lines
3.4 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|